Skip to content

Commit 4806c08

Browse files
committed
feat(trace-panel): add collapsible JSON tree view for tool outputs and enhance tool output rendering
1 parent 669390e commit 4806c08

1 file changed

Lines changed: 148 additions & 37 deletions

File tree

src/client/components/trace-panel.tsx

Lines changed: 148 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
&quot;{value}&quot;
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">&quot;{k}&quot;</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 */
24166
function 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

Comments
 (0)