@@ -20,6 +20,148 @@ interface TracePanelProps {
2020 sessionId : string | null ;
2121}
2222
23+ /** Collapsible JSON tree view for tool outputs */
24+ function JsonNode ( { value, depth = 0 } : { value : unknown ; depth ?: number } ) {
25+ const [ collapsed , setCollapsed ] = useState ( depth > 1 ) ;
26+
27+ if ( value === null ) return < span className = "text-gray-400 dark:text-gray-500" > null</ span > ;
28+ if ( typeof value === "boolean" )
29+ return < span className = "text-yellow-600 dark:text-yellow-400" > { String ( value ) } </ span > ;
30+ if ( typeof value === "number" )
31+ return < span className = "text-blue-600 dark:text-blue-400" > { value } </ span > ;
32+ if ( typeof value === "string" )
33+ return (
34+ < span className = "text-green-700 dark:text-green-400" >
35+ "{ value } "
36+ </ span >
37+ ) ;
38+
39+ if ( Array . isArray ( value ) ) {
40+ if ( value . length === 0 ) return < span className = "text-gray-500" > []</ span > ;
41+ return (
42+ < span >
43+ < button
44+ onClick = { ( ) => setCollapsed ( ( c ) => ! c ) }
45+ className = "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 font-mono"
46+ >
47+ { collapsed ? `[…${ value . length } ]` : "[" }
48+ </ button >
49+ { ! collapsed && (
50+ < >
51+ < div className = "pl-3 border-l border-gray-200 dark:border-gray-700 ml-1" >
52+ { value . map ( ( item , i ) => (
53+ < div key = { i } className = "my-0.5" >
54+ < span className = "text-gray-400 dark:text-gray-500 select-none" > { i } : </ span >
55+ < JsonNode value = { item } depth = { depth + 1 } />
56+ { i < value . length - 1 && < span className = "text-gray-400" > ,</ span > }
57+ </ div >
58+ ) ) }
59+ </ div >
60+ < span className = "text-gray-500" > ]</ span >
61+ </ >
62+ ) }
63+ </ span >
64+ ) ;
65+ }
66+
67+ if ( typeof value === "object" ) {
68+ const entries = Object . entries ( value as Record < string , unknown > ) ;
69+ if ( entries . length === 0 ) return < span className = "text-gray-500" > { "{}" } </ span > ;
70+ return (
71+ < span >
72+ < button
73+ onClick = { ( ) => setCollapsed ( ( c ) => ! c ) }
74+ className = "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 font-mono"
75+ >
76+ { collapsed ? `{…${ entries . length } }` : "{" }
77+ </ button >
78+ { ! collapsed && (
79+ < >
80+ < div className = "pl-3 border-l border-gray-200 dark:border-gray-700 ml-1" >
81+ { entries . map ( ( [ k , v ] , i ) => (
82+ < div key = { k } className = "my-0.5" >
83+ < span className = "text-purple-700 dark:text-purple-400 font-semibold" > "{ k } "</ span >
84+ < span className = "text-gray-500" > : </ span >
85+ < JsonNode value = { v } depth = { depth + 1 } />
86+ { i < entries . length - 1 && < span className = "text-gray-400" > ,</ span > }
87+ </ div >
88+ ) ) }
89+ </ div >
90+ < span className = "text-gray-500" > { "}" } </ span >
91+ </ >
92+ ) }
93+ </ span >
94+ ) ;
95+ }
96+
97+ return < span className = "text-gray-700 dark:text-gray-300" > { String ( value ) } </ span > ;
98+ }
99+
100+ /** Try to parse a string as JSON; return parsed value or null */
101+ function tryParseJson ( s : string ) : unknown | null {
102+ const trimmed = s . trim ( ) ;
103+ if ( ! ( trimmed . startsWith ( "{" ) || trimmed . startsWith ( "[" ) ) ) return null ;
104+ try {
105+ return JSON . parse ( trimmed ) ;
106+ } catch {
107+ return null ;
108+ }
109+ }
110+
111+ /** Render tool output: JSON tree if parseable, otherwise plain pre */
112+ function ToolOutput ( { output } : { output : string } ) {
113+ const parsed = useMemo ( ( ) => tryParseJson ( output ) , [ output ] ) ;
114+ const [ mode , setMode ] = useState < "tree" | "raw" > ( "tree" ) ;
115+
116+ if ( ! parsed )
117+ return (
118+ < pre className = "px-2 py-1.5 text-[10px] font-mono text-gray-700 dark:text-gray-200 whitespace-pre-wrap break-words bg-white dark:bg-gray-900/40" >
119+ { output }
120+ </ pre >
121+ ) ;
122+
123+ return (
124+ < div >
125+ < div className = "px-2 py-1 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700/60 flex items-center justify-between" >
126+ < span className = "text-[9px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider" >
127+ Output
128+ </ span >
129+ < div className = "flex gap-1" >
130+ < button
131+ onClick = { ( ) => setMode ( "tree" ) }
132+ className = { `text-[9px] px-1.5 py-0.5 rounded transition-colors ${
133+ mode === "tree"
134+ ? "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300"
135+ : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
136+ } `}
137+ >
138+ Tree
139+ </ button >
140+ < button
141+ onClick = { ( ) => setMode ( "raw" ) }
142+ className = { `text-[9px] px-1.5 py-0.5 rounded transition-colors ${
143+ mode === "raw"
144+ ? "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300"
145+ : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
146+ } `}
147+ >
148+ Raw
149+ </ button >
150+ </ div >
151+ </ div >
152+ { mode === "tree" ? (
153+ < div className = "px-2 py-1.5 text-[10px] font-mono bg-white dark:bg-gray-900/40 overflow-auto" >
154+ < JsonNode value = { parsed } depth = { 0 } />
155+ </ div >
156+ ) : (
157+ < pre className = "px-2 py-1.5 text-[10px] font-mono text-gray-700 dark:text-gray-200 whitespace-pre-wrap break-words bg-white dark:bg-gray-900/40" >
158+ { output }
159+ </ pre >
160+ ) }
161+ </ div >
162+ ) ;
163+ }
164+
23165/** Render tool input parameters as a visible key-value table */
24166function ToolInputTable ( { input } : { input : unknown } ) {
25167 if ( input == null ) return null ;
@@ -68,7 +210,7 @@ export function TracePanel({ sessionId }: TracePanelProps) {
68210 const [ loading , setLoading ] = useState ( false ) ;
69211 const [ error , setError ] = useState < string | null > ( null ) ;
70212 const [ filter , setFilter ] = useState < string > ( "all" ) ;
71- const [ expandedOutputIds , setExpandedOutputIds ] = useState < Set < string > > ( new Set ( ) ) ;
213+
72214 const [ stats , setStats ] = useState < {
73215 totalDays : number ;
74216 totalFiles : number ;
@@ -156,14 +298,7 @@ export function TracePanel({ sessionId }: TracePanelProps) {
156298
157299 const sessionGroups = useMemo ( ( ) => groupBySession ( filteredTraces ) , [ filteredTraces ] ) ;
158300
159- const toggleOutputExpand = useCallback ( ( traceId : string ) => {
160- setExpandedOutputIds ( ( prev ) => {
161- const next = new Set ( prev ) ;
162- if ( next . has ( traceId ) ) next . delete ( traceId ) ;
163- else next . add ( traceId ) ;
164- return next ;
165- } ) ;
166- } , [ ] ) ;
301+
167302
168303 const formatTime = ( timestamp : string ) => {
169304 const date = new Date ( timestamp ) ;
@@ -307,7 +442,6 @@ export function TracePanel({ sessionId }: TracePanelProps) {
307442 { /* Events */ }
308443 < div className = "divide-y divide-gray-100 dark:divide-gray-800/60" >
309444 { sessionTraces . map ( ( trace ) => {
310- const isOutputExpanded = expandedOutputIds . has ( trace . id ) ;
311445
312446 /* ── Session lifecycle ── */
313447 if ( trace . eventType === "session_start" || trace . eventType === "session_end" ) {
@@ -357,7 +491,7 @@ export function TracePanel({ sessionId }: TracePanelProps) {
357491 const content =
358492 trace . conversation ?. fullContent || trace . conversation ?. contentPreview || "" ;
359493 return (
360- < div key = { trace . id } className = "px-4 py-2.5 flex gap-3" >
494+ < div key = { trace . id } className = "px-4 py-2.5 flex gap-3 border-l-2 border-purple-300 dark:border-purple-700 " >
361495 < span className = "text-[10px] font-mono text-gray-400 dark:text-gray-500 shrink-0 w-16 text-right pt-0.5" >
362496 { formatTime ( trace . timestamp ) }
363497 </ span >
@@ -404,7 +538,7 @@ export function TracePanel({ sessionId }: TracePanelProps) {
404538 /* ── Tool call ── */
405539 if ( trace . eventType === "tool_call" ) {
406540 return (
407- < div key = { trace . id } className = "px-4 py-2.5 flex gap-3" >
541+ < div key = { trace . id } className = "px-4 py-2.5 flex gap-3 border-l-2 border-orange-300 dark:border-orange-700 " >
408542 < span className = "text-[10px] font-mono text-gray-400 dark:text-gray-500 shrink-0 w-16 text-right pt-0.5" >
409543 { formatTime ( trace . timestamp ) }
410544 </ span >
@@ -476,14 +610,9 @@ export function TracePanel({ sessionId }: TracePanelProps) {
476610 : typeof rawOutput === "string"
477611 ? rawOutput
478612 : JSON . stringify ( rawOutput , null , 2 ) ;
479- const TRUNCATE_LEN = 400 ;
480- const isTruncated = outputStr . length > TRUNCATE_LEN ;
481- const displayOutput = isOutputExpanded
482- ? outputStr
483- : outputStr . slice ( 0 , TRUNCATE_LEN ) ;
484613
485614 return (
486- < div key = { trace . id } className = "px-4 py-2.5 flex gap-3" >
615+ < div key = { trace . id } className = "px-4 py-2.5 flex gap-3 bg-cyan-50/30 dark:bg-cyan-900/5 border-l-2 border-cyan-300 dark:border-cyan-700 " >
487616 < span className = "text-[10px] font-mono text-gray-400 dark:text-gray-500 shrink-0 w-16 text-right pt-0.5" >
488617 { formatTime ( trace . timestamp ) }
489618 </ span >
@@ -511,25 +640,7 @@ export function TracePanel({ sessionId }: TracePanelProps) {
511640 </ div >
512641 { outputStr && (
513642 < div className = "rounded-md border border-gray-200 dark:border-gray-700/60 overflow-hidden" >
514- < div className = "px-2 py-1 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700/60" >
515- < span className = "text-[9px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider" >
516- Output
517- </ span >
518- </ div >
519- < pre className = "px-2 py-1.5 text-[10px] font-mono text-gray-700 dark:text-gray-200 whitespace-pre-wrap break-words bg-white dark:bg-gray-900/40" >
520- { displayOutput }
521- { isTruncated && ! isOutputExpanded && "…" }
522- </ pre >
523- { isTruncated && (
524- < button
525- onClick = { ( ) => toggleOutputExpand ( trace . id ) }
526- className = "w-full px-2 py-1 text-[10px] text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800/40 border-t border-gray-200 dark:border-gray-700/60 transition-colors text-left"
527- >
528- { isOutputExpanded
529- ? "Show less"
530- : `Show all (${ outputStr . length } chars)` }
531- </ button >
532- ) }
643+ < ToolOutput output = { outputStr } />
533644 </ div >
534645 ) }
535646 </ div >
0 commit comments