77 ChevronRight ,
88 Clock ,
99 Copy ,
10+ ExternalLink ,
1011 Loader2 ,
1112 Play ,
1213 X ,
@@ -15,6 +16,10 @@ import Image from "next/image";
1516import type { JSX } from "react" ;
1617import { useCallback , useEffect , useRef , useState } from "react" ;
1718import { api } from "@/lib/api-client" ;
19+ import {
20+ OUTPUT_DISPLAY_CONFIGS ,
21+ type OutputDisplayConfig ,
22+ } from "@/lib/output-display-configs" ;
1823import { cn } from "@/lib/utils" ;
1924import { getRelativeTime } from "@/lib/utils/time" ;
2025import {
@@ -55,7 +60,27 @@ type WorkflowRunsProps = {
5560 onStartRun ?: ( executionId : string ) => void ;
5661} ;
5762
58- // Helper to detect if output is a base64 image from generateImage step
63+ // Helper to get the output display config for a node type
64+ function getOutputConfig ( nodeType : string ) : OutputDisplayConfig | undefined {
65+ return OUTPUT_DISPLAY_CONFIGS [ nodeType ] ;
66+ }
67+
68+ // Helper to extract the displayable value from output based on config
69+ function getOutputDisplayValue (
70+ output : unknown ,
71+ config : OutputDisplayConfig
72+ ) : string | undefined {
73+ if ( typeof output !== "object" || output === null ) {
74+ return ;
75+ }
76+ const value = ( output as Record < string , unknown > ) [ config . field ] ;
77+ if ( typeof value === "string" && value . length > 0 ) {
78+ return value ;
79+ }
80+ return ;
81+ }
82+
83+ // Fallback: detect if output is a base64 image (for legacy support)
5984function isBase64ImageOutput ( output : unknown ) : output is { base64 : string } {
6085 return (
6186 typeof output === "object" &&
@@ -99,6 +124,51 @@ function createExecutionLogsMap(logs: ExecutionLog[]): Record<
99124 return logsMap ;
100125}
101126
127+ // Helper to check if a string is a URL
128+ function isUrl ( str : string ) : boolean {
129+ try {
130+ const url = new URL ( str ) ;
131+ return url . protocol === "http:" || url . protocol === "https:" ;
132+ } catch {
133+ return false ;
134+ }
135+ }
136+
137+ // Component to render JSON with clickable links
138+ function JsonWithLinks ( { data } : { data : unknown } ) {
139+ // Use regex to find and replace URLs in the JSON string
140+ const jsonString = JSON . stringify ( data , null , 2 ) ;
141+
142+ // Split by quoted strings to preserve structure
143+ const parts = jsonString . split ( / ( " h t t p s ? : \/ \/ [ ^ " ] + " | " [ ^ " ] * " ) / g) ;
144+
145+ return (
146+ < >
147+ { parts . map ( ( part ) => {
148+ // Check if this part is a quoted URL string
149+ if ( part . startsWith ( '"' ) && part . endsWith ( '"' ) ) {
150+ const innerValue = part . slice ( 1 , - 1 ) ;
151+ if ( isUrl ( innerValue ) ) {
152+ return (
153+ < a
154+ className = "text-blue-500 underline hover:text-blue-400"
155+ href = { innerValue }
156+ key = { innerValue }
157+ rel = "noopener noreferrer"
158+ target = "_blank"
159+ >
160+ { part }
161+ </ a >
162+ ) ;
163+ }
164+ }
165+ // For non-URL parts, just render as text (no key needed for text nodes)
166+ return part ;
167+ } ) }
168+ </ >
169+ ) ;
170+ }
171+
102172// Reusable copy button component
103173function CopyButton ( {
104174 data,
@@ -138,6 +208,176 @@ function CopyButton({
138208 ) ;
139209}
140210
211+ // Collapsible section component
212+ function CollapsibleSection ( {
213+ title,
214+ children,
215+ defaultExpanded = false ,
216+ copyData,
217+ isError = false ,
218+ externalLink,
219+ } : {
220+ title : string ;
221+ children : React . ReactNode ;
222+ defaultExpanded ?: boolean ;
223+ copyData ?: unknown ;
224+ isError ?: boolean ;
225+ externalLink ?: string ;
226+ } ) {
227+ const [ isOpen , setIsOpen ] = useState ( defaultExpanded ) ;
228+
229+ return (
230+ < div >
231+ < div className = "mb-2 flex w-full items-center justify-between" >
232+ < button
233+ className = "flex items-center gap-1.5"
234+ onClick = { ( ) => setIsOpen ( ! isOpen ) }
235+ type = "button"
236+ >
237+ { isOpen ? (
238+ < ChevronDown className = "h-3 w-3 text-muted-foreground" />
239+ ) : (
240+ < ChevronRight className = "h-3 w-3 text-muted-foreground" />
241+ ) }
242+ < span className = "font-medium text-muted-foreground text-xs uppercase tracking-wide" >
243+ { title }
244+ </ span >
245+ </ button >
246+ < div className = "flex items-center gap-1" >
247+ { externalLink && (
248+ < Button asChild className = "h-7 px-2" size = "sm" variant = "ghost" >
249+ < a href = { externalLink } rel = "noopener noreferrer" target = "_blank" >
250+ < ExternalLink className = "h-3 w-3" />
251+ </ a >
252+ </ Button >
253+ ) }
254+ { copyData !== undefined && (
255+ < CopyButton data = { copyData } isError = { isError } />
256+ ) }
257+ </ div >
258+ </ div >
259+ { isOpen && children }
260+ </ div >
261+ ) ;
262+ }
263+
264+ // Component for rendering output with rich display support
265+ function OutputDisplay ( {
266+ output,
267+ input,
268+ } : {
269+ output : unknown ;
270+ input ?: unknown ;
271+ } ) {
272+ // Get actionType from input to look up the output config
273+ const actionType =
274+ typeof input === "object" && input !== null
275+ ? ( input as Record < string , unknown > ) . actionType
276+ : undefined ;
277+ const config =
278+ typeof actionType === "string" ? getOutputConfig ( actionType ) : undefined ;
279+ const displayValue = config
280+ ? getOutputDisplayValue ( output , config )
281+ : undefined ;
282+
283+ // Check for legacy base64 image
284+ const isLegacyBase64 = ! config && isBase64ImageOutput ( output ) ;
285+
286+ const renderRichResult = ( ) => {
287+ if ( config && displayValue ) {
288+ switch ( config . type ) {
289+ case "image" : {
290+ // Handle base64 images by adding data URI prefix if needed
291+ const imageSrc =
292+ config . field === "base64" && ! displayValue . startsWith ( "data:" )
293+ ? `data:image/png;base64,${ displayValue } `
294+ : displayValue ;
295+ return (
296+ < div className = "overflow-hidden rounded-lg border bg-muted/50 p-3" >
297+ < Image
298+ alt = "Generated image"
299+ className = "max-h-96 w-auto rounded"
300+ height = { 384 }
301+ src = { imageSrc }
302+ unoptimized
303+ width = { 384 }
304+ />
305+ </ div >
306+ ) ;
307+ }
308+ case "video" :
309+ return (
310+ < div className = "overflow-hidden rounded-lg border bg-muted/50 p-3" >
311+ < video
312+ className = "max-h-96 w-auto rounded"
313+ controls
314+ src = { displayValue }
315+ >
316+ < track kind = "captions" />
317+ </ video >
318+ </ div >
319+ ) ;
320+ case "url" :
321+ return (
322+ < div className = "overflow-hidden rounded-lg border bg-muted/50" >
323+ < iframe
324+ className = "h-96 w-full rounded"
325+ sandbox = "allow-scripts allow-same-origin"
326+ src = { displayValue }
327+ title = "Output preview"
328+ />
329+ </ div >
330+ ) ;
331+ default :
332+ return null ;
333+ }
334+ }
335+
336+ // Fallback: legacy base64 image detection
337+ if ( isLegacyBase64 ) {
338+ return (
339+ < div className = "overflow-hidden rounded-lg border bg-muted/50 p-3" >
340+ < Image
341+ alt = "AI generated output"
342+ className = "max-h-96 w-auto rounded"
343+ height = { 384 }
344+ src = { `data:image/png;base64,${ ( output as { base64 : string } ) . base64 } ` }
345+ unoptimized
346+ width = { 384 }
347+ />
348+ </ div >
349+ ) ;
350+ }
351+
352+ return null ;
353+ } ;
354+
355+ const richResult = renderRichResult ( ) ;
356+ const hasRichResult = richResult !== null ;
357+
358+ return (
359+ < >
360+ { /* Always show JSON output */ }
361+ < CollapsibleSection copyData = { output } title = "Output" >
362+ < pre className = "overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed" >
363+ < JsonWithLinks data = { output } />
364+ </ pre >
365+ </ CollapsibleSection >
366+
367+ { /* Show rich result if available */ }
368+ { hasRichResult && (
369+ < CollapsibleSection
370+ defaultExpanded
371+ externalLink = { config ?. type === "url" ? displayValue : undefined }
372+ title = "Result"
373+ >
374+ { richResult }
375+ </ CollapsibleSection >
376+ ) }
377+ </ >
378+ ) ;
379+ }
380+
141381// Component for rendering individual execution log entries
142382function ExecutionLogEntry ( {
143383 log,
@@ -211,56 +451,26 @@ function ExecutionLogEntry({
211451 { isExpanded && (
212452 < div className = "mt-2 mb-2 space-y-3 px-3" >
213453 { log . input !== null && log . input !== undefined && (
214- < div >
215- < div className = "mb-2 flex items-center justify-between" >
216- < div className = "font-medium text-muted-foreground text-xs uppercase tracking-wide" >
217- Input
218- </ div >
219- < CopyButton data = { log . input } />
220- </ div >
454+ < CollapsibleSection copyData = { log . input } title = "Input" >
221455 < pre className = "overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed" >
222- { JSON . stringify ( log . input , null , 2 ) }
456+ < JsonWithLinks data = { log . input } />
223457 </ pre >
224- </ div >
458+ </ CollapsibleSection >
225459 ) }
226460 { log . output !== null && log . output !== undefined && (
227- < div >
228- < div className = "mb-2 flex items-center justify-between" >
229- < div className = "font-medium text-muted-foreground text-xs uppercase tracking-wide" >
230- Output
231- </ div >
232- < CopyButton data = { log . output } />
233- </ div >
234- { isBase64ImageOutput ( log . output ) ? (
235- < div className = "overflow-hidden rounded-lg border bg-muted/50 p-3" >
236- < Image
237- alt = "AI generated output"
238- className = "max-h-96 w-auto rounded"
239- height = { 384 }
240- src = { `data:image/png;base64,${ ( log . output as { base64 : string } ) . base64 } ` }
241- unoptimized
242- width = { 384 }
243- />
244- </ div >
245- ) : (
246- < pre className = "overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed" >
247- { JSON . stringify ( log . output , null , 2 ) }
248- </ pre >
249- ) }
250- </ div >
461+ < OutputDisplay input = { log . input } output = { log . output } />
251462 ) }
252463 { log . error && (
253- < div >
254- < div className = "mb-2 flex items-center justify-between" >
255- < div className = "font-medium text-muted-foreground text-xs uppercase tracking-wide" >
256- Error
257- </ div >
258- < CopyButton data = { log . error } isError />
259- </ div >
464+ < CollapsibleSection
465+ copyData = { log . error }
466+ defaultExpanded
467+ isError
468+ title = "Error"
469+ >
260470 < pre className = "overflow-auto rounded-lg border border-red-500/20 bg-red-500/5 p-3 font-mono text-red-600 text-xs leading-relaxed" >
261471 { log . error }
262472 </ pre >
263- </ div >
473+ </ CollapsibleSection >
264474 ) }
265475 { ! ( log . input || log . output || log . error ) && (
266476 < div className = "rounded-lg border bg-muted/30 py-4 text-center text-muted-foreground text-xs" >
0 commit comments