llm-deep-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/bin/llm-deep-trace.js +24 -0
  4. package/next.config.ts +8 -0
  5. package/package.json +56 -0
  6. package/postcss.config.mjs +5 -0
  7. package/public/banner-v2.png +0 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/logo.png +0 -0
  11. package/public/next.svg +1 -0
  12. package/public/vercel.svg +1 -0
  13. package/public/window.svg +1 -0
  14. package/src/app/api/agent-config/route.ts +31 -0
  15. package/src/app/api/all-sessions/route.ts +9 -0
  16. package/src/app/api/analytics/route.ts +379 -0
  17. package/src/app/api/detect-agents/route.ts +170 -0
  18. package/src/app/api/image/route.ts +73 -0
  19. package/src/app/api/search/route.ts +28 -0
  20. package/src/app/api/session-by-key/route.ts +21 -0
  21. package/src/app/api/sessions/[sessionId]/messages/route.ts +46 -0
  22. package/src/app/api/sse/route.ts +86 -0
  23. package/src/app/favicon.ico +0 -0
  24. package/src/app/globals.css +3518 -0
  25. package/src/app/icon.svg +4 -0
  26. package/src/app/layout.tsx +20 -0
  27. package/src/app/page.tsx +5 -0
  28. package/src/components/AnalyticsDashboard.tsx +393 -0
  29. package/src/components/App.tsx +243 -0
  30. package/src/components/CopyButton.tsx +42 -0
  31. package/src/components/Logo.tsx +20 -0
  32. package/src/components/MainPanel.tsx +1128 -0
  33. package/src/components/MessageRenderer.tsx +983 -0
  34. package/src/components/SessionTree.tsx +505 -0
  35. package/src/components/SettingsPanel.tsx +160 -0
  36. package/src/components/SetupView.tsx +206 -0
  37. package/src/components/Sidebar.tsx +714 -0
  38. package/src/components/ThemeToggle.tsx +54 -0
  39. package/src/lib/client-utils.ts +360 -0
  40. package/src/lib/normalizers.ts +371 -0
  41. package/src/lib/sessions.ts +1223 -0
  42. package/src/lib/store.ts +518 -0
  43. package/src/lib/types.ts +112 -0
  44. package/src/lib/useSSE.ts +81 -0
  45. package/tsconfig.json +34 -0
@@ -0,0 +1,505 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useCallback, useEffect, useRef } from "react";
4
+ import {
5
+ ReactFlow,
6
+ Node,
7
+ Edge,
8
+ useReactFlow,
9
+ ReactFlowProvider,
10
+ NodeProps,
11
+ Handle,
12
+ Position,
13
+ } from "@xyflow/react";
14
+ import "@xyflow/react/dist/style.css";
15
+ import { NormalizedMessage, ContentBlock } from "@/lib/types";
16
+ import { extractText, cleanPreview } from "@/lib/client-utils";
17
+
18
+ // ── Types ──
19
+
20
+ interface TurnInfo {
21
+ turnIndex: number;
22
+ messageIndex: number;
23
+ preview: string;
24
+ subagents: SubagentInfo[];
25
+ }
26
+
27
+ interface SubagentInfo {
28
+ label: string;
29
+ fullLabel: string;
30
+ agentKey: string;
31
+ }
32
+
33
+ interface RootNodeData {
34
+ label: string;
35
+ fullLabel: string;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ interface TurnNodeData {
40
+ turnIndex: number;
41
+ messageIndex: number;
42
+ preview: string;
43
+ hasSubagents: boolean;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ interface SubagentNodeData {
48
+ label: string;
49
+ fullLabel: string;
50
+ agentKey: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ interface SessionTreeProps {
55
+ messages: NormalizedMessage[];
56
+ sessionId: string;
57
+ sessionLabel: string;
58
+ allSessions: import("@/lib/types").SessionInfo[];
59
+ onScrollToMessage: (messageIndex: number) => void;
60
+ onNavigateSession: (sessionKey: string) => void;
61
+ onClose: () => void;
62
+ }
63
+
64
+ // ── Extract turn structure from messages ──
65
+
66
+ function buildTurns(messages: NormalizedMessage[]): TurnInfo[] {
67
+ const turns: TurnInfo[] = [];
68
+ let turnIndex = 0;
69
+
70
+ // Build a map of toolCallId → result text for extracting agentId from results
71
+ const toolResultMap = new Map<string, string>();
72
+ for (const msg of messages) {
73
+ if (msg.message?.role === "toolResult" && msg.message.toolCallId) {
74
+ const text = extractText(msg.message.content);
75
+ toolResultMap.set(msg.message.toolCallId, text);
76
+ }
77
+ }
78
+
79
+ for (let i = 0; i < messages.length; i++) {
80
+ const msg = messages[i];
81
+ if (msg.message?.role !== "user") continue;
82
+ if (msg.type === "tool_result" || msg.message.toolCallId) continue;
83
+
84
+ turnIndex++;
85
+ const userText = extractText(msg.message.content);
86
+ const preview = cleanPreview(userText).slice(0, 45) || `Turn ${turnIndex}`;
87
+
88
+ const subagents: SubagentInfo[] = [];
89
+
90
+ // Scan forward for assistant responses until next user turn
91
+ for (let j = i + 1; j < messages.length; j++) {
92
+ const next = messages[j];
93
+ if (
94
+ next.message?.role === "user" &&
95
+ !next.message.toolCallId &&
96
+ next.type !== "tool_result"
97
+ )
98
+ break;
99
+
100
+ if (
101
+ next.message?.role === "assistant" &&
102
+ Array.isArray(next.message.content)
103
+ ) {
104
+ for (const block of next.message.content as ContentBlock[]) {
105
+ if (
106
+ block.type === "tool_use" &&
107
+ (block.name === "sessions_spawn" ||
108
+ block.name === "Task" ||
109
+ block.name === "task")
110
+ ) {
111
+ const input = block.input || {};
112
+ const desc =
113
+ (input.description as string) ||
114
+ (input.name as string) ||
115
+ (input.prompt as string) ||
116
+ "subagent";
117
+ const fullDesc = desc.trim();
118
+ const label = fullDesc.slice(0, 30);
119
+
120
+ // Try to extract agentId from the tool result
121
+ let agentKey = "";
122
+ const resultText = block.id ? toolResultMap.get(block.id) || "" : "";
123
+ // Claude Code results contain "agentId: XXXXX"
124
+ const agentIdMatch = resultText.match(/agentId:\s*([a-zA-Z0-9_-]+)/);
125
+ if (agentIdMatch) {
126
+ agentKey = agentIdMatch[1];
127
+ }
128
+ // For sessions_spawn, try parsing result as JSON
129
+ if (!agentKey && resultText) {
130
+ try {
131
+ const parsed = JSON.parse(resultText);
132
+ agentKey = parsed.childSessionId || parsed.childSessionKey || parsed.agentId || "";
133
+ } catch { /* not JSON */ }
134
+ }
135
+ // Fallback to input fields
136
+ if (!agentKey) {
137
+ agentKey =
138
+ (input.agentId as string) ||
139
+ (input.name as string) ||
140
+ (input.sessionId as string) ||
141
+ block.id ||
142
+ "";
143
+ }
144
+ subagents.push({ label, fullLabel: fullDesc, agentKey });
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ turns.push({ turnIndex, messageIndex: i, preview, subagents });
151
+ }
152
+
153
+ return turns;
154
+ }
155
+
156
+ // ── Filter turns for display: if >50, show every 5th + turns with subagent spawns ──
157
+
158
+ function filterTurns(turns: TurnInfo[]): TurnInfo[] {
159
+ if (turns.length <= 50) return turns;
160
+ return turns.filter(
161
+ (t) => t.turnIndex % 5 === 0 || t.subagents.length > 0
162
+ );
163
+ }
164
+
165
+ // ── Custom Nodes ──
166
+
167
+ function RootNodeComponent({ data }: NodeProps<Node<RootNodeData>>) {
168
+ return (
169
+ <>
170
+ <div className="tree-node-card tree-node-root" title={data.fullLabel || data.label}>
171
+ <div className="tree-node-top">
172
+ <span className="tree-node-title">{data.label}</span>
173
+ </div>
174
+ </div>
175
+ <Handle
176
+ type="source"
177
+ position={Position.Bottom}
178
+ className="tree-handle"
179
+ />
180
+ </>
181
+ );
182
+ }
183
+
184
+ function TurnNodeComponent({ data }: NodeProps<Node<TurnNodeData>>) {
185
+ return (
186
+ <>
187
+ <Handle type="target" position={Position.Top} className="tree-handle" />
188
+ <div className="tree-node-card tree-node-turn" title={data.preview}>
189
+ <div className="tree-node-top">
190
+ <span className="tree-turn-label">Turn {data.turnIndex}</span>
191
+ </div>
192
+ <div className="tree-node-preview">{data.preview}</div>
193
+ </div>
194
+ {data.hasSubagents && (
195
+ <Handle
196
+ type="source"
197
+ position={Position.Right}
198
+ id="right"
199
+ className="tree-handle"
200
+ />
201
+ )}
202
+ <Handle
203
+ type="source"
204
+ position={Position.Bottom}
205
+ id="bottom"
206
+ className="tree-handle"
207
+ />
208
+ </>
209
+ );
210
+ }
211
+
212
+ function SubagentNodeComponent({ data }: NodeProps<Node<SubagentNodeData>>) {
213
+ return (
214
+ <>
215
+ <Handle type="target" position={Position.Left} className="tree-handle" />
216
+ <div className="tree-node-card tree-node-subagent" title={data.fullLabel || data.label}>
217
+ <div className="tree-node-top">
218
+ <span className="tree-node-title">{data.label}</span>
219
+ <span className="tree-node-badge">subagent</span>
220
+ </div>
221
+ </div>
222
+ </>
223
+ );
224
+ }
225
+
226
+ const nodeTypes = {
227
+ rootNode: RootNodeComponent,
228
+ turnNode: TurnNodeComponent,
229
+ subagentNode: SubagentNodeComponent,
230
+ };
231
+
232
+ // ── Layout ──
233
+
234
+ function layoutTurns(
235
+ turns: TurnInfo[],
236
+ label: string
237
+ ): { nodes: Node[]; edges: Edge[] } {
238
+ const nodes: Node[] = [];
239
+ const edges: Edge[] = [];
240
+
241
+ const TURN_X = 0;
242
+ const SUBAGENT_X = 280;
243
+ const ROW_HEIGHT = 100;
244
+ const SUB_ROW_HEIGHT = 56;
245
+ const ROOT_Y = 0;
246
+
247
+ // Root session node
248
+ nodes.push({
249
+ id: "root",
250
+ type: "rootNode",
251
+ position: { x: TURN_X, y: ROOT_Y },
252
+ data: { label: label.slice(0, 30), fullLabel: label },
253
+ });
254
+
255
+ let y = 70;
256
+
257
+ for (let i = 0; i < turns.length; i++) {
258
+ const turn = turns[i];
259
+ const turnId = `turn-${turn.turnIndex}`;
260
+
261
+ nodes.push({
262
+ id: turnId,
263
+ type: "turnNode",
264
+ position: { x: TURN_X, y },
265
+ data: {
266
+ turnIndex: turn.turnIndex,
267
+ messageIndex: turn.messageIndex,
268
+ preview: turn.preview,
269
+ hasSubagents: turn.subagents.length > 0,
270
+ },
271
+ });
272
+
273
+ // Edge from previous
274
+ const prevId = i === 0 ? "root" : `turn-${turns[i - 1].turnIndex}`;
275
+ edges.push({
276
+ id: `${prevId}->${turnId}`,
277
+ source: prevId,
278
+ target: turnId,
279
+ sourceHandle: prevId === "root" ? undefined : "bottom",
280
+ type: "straight",
281
+ style: { stroke: "var(--border)", strokeWidth: 1 },
282
+ });
283
+
284
+ // Subagent branches to the right
285
+ for (let j = 0; j < turn.subagents.length; j++) {
286
+ const sub = turn.subagents[j];
287
+ const subId = `${turnId}-sub-${j}`;
288
+ const subY = y + j * SUB_ROW_HEIGHT;
289
+
290
+ nodes.push({
291
+ id: subId,
292
+ type: "subagentNode",
293
+ position: { x: SUBAGENT_X, y: subY },
294
+ data: { label: sub.label, fullLabel: sub.fullLabel || sub.label, agentKey: sub.agentKey },
295
+ });
296
+
297
+ edges.push({
298
+ id: `${turnId}->${subId}`,
299
+ source: turnId,
300
+ target: subId,
301
+ sourceHandle: "right",
302
+ type: "straight",
303
+ style: { stroke: "var(--border)", strokeWidth: 1 },
304
+ });
305
+ }
306
+
307
+ // Advance Y, accounting for subagent stack height
308
+ const subHeight =
309
+ turn.subagents.length > 1
310
+ ? (turn.subagents.length - 1) * SUB_ROW_HEIGHT
311
+ : 0;
312
+ y += Math.max(ROW_HEIGHT, subHeight + ROW_HEIGHT);
313
+ }
314
+
315
+ return { nodes, edges };
316
+ }
317
+
318
+ // ── Inner Flow (needs ReactFlowProvider) ──
319
+
320
+ function InnerFlow({
321
+ messages,
322
+ sessionId: currentSessionId,
323
+ sessionLabel: label,
324
+ allSessions,
325
+ onScrollToMessage,
326
+ onNavigateSession,
327
+ onResetView,
328
+ }: {
329
+ messages: NormalizedMessage[];
330
+ sessionId: string;
331
+ sessionLabel: string;
332
+ allSessions: import("@/lib/types").SessionInfo[];
333
+ onScrollToMessage: (messageIndex: number) => void;
334
+ onNavigateSession: (sessionKey: string) => void;
335
+ onResetView?: (fn: () => void) => void;
336
+ }) {
337
+ const { fitView } = useReactFlow();
338
+
339
+ // Real child sessions from the session index (works for both Task and TaskCreate/Agent Teams)
340
+ const childSessions = useMemo(
341
+ () => allSessions.filter(
342
+ (s) => s.parentSessionId === currentSessionId || s.parentSessionId === currentSessionId
343
+ ),
344
+ [allSessions, currentSessionId]
345
+ );
346
+
347
+ // Build turns from messages, then enrich subagent nodes with real session IDs
348
+ const turns = useMemo(() => {
349
+ const raw = filterTurns(buildTurns(messages));
350
+
351
+ // For each turn's subagents, try to resolve agentKey → real sessionId
352
+ for (const turn of raw) {
353
+ for (const sub of turn.subagents) {
354
+ if (!sub.agentKey) continue;
355
+ // Try to find a matching child session
356
+ const match = childSessions.find(
357
+ (s) =>
358
+ s.sessionId === sub.agentKey ||
359
+ s.sessionId.includes(sub.agentKey) ||
360
+ ("agent-" + sub.agentKey) === s.sessionId ||
361
+ s.sessionId.endsWith(sub.agentKey)
362
+ );
363
+ if (match) sub.agentKey = match.sessionId;
364
+ }
365
+ }
366
+
367
+ // Add any child sessions not already covered by turns (Agent Teams: TaskCreate-based)
368
+ const coveredIds = new Set(raw.flatMap((t) => t.subagents.map((s) => s.agentKey)));
369
+ const uncovered = childSessions.filter((s) => !coveredIds.has(s.sessionId));
370
+ if (uncovered.length > 0 && raw.length > 0) {
371
+ // Attach uncovered children to the last turn as a group
372
+ const lastTurn = raw[raw.length - 1];
373
+ for (const s of uncovered) {
374
+ const fullSub = s.label || s.sessionId;
375
+ lastTurn.subagents.push({
376
+ label: fullSub.slice(0, 30),
377
+ fullLabel: fullSub,
378
+ agentKey: s.sessionId,
379
+ });
380
+ }
381
+ }
382
+
383
+ return raw;
384
+ }, [messages, childSessions]);
385
+
386
+ const { nodes, edges } = useMemo(
387
+ () => layoutTurns(turns, label),
388
+ [turns, label]
389
+ );
390
+
391
+ // Fit once on mount only — never re-fit while user is browsing
392
+ const hasFitted = useRef(false);
393
+ useEffect(() => {
394
+ if (hasFitted.current || nodes.length === 0) return;
395
+ hasFitted.current = true;
396
+ const timer = setTimeout(() => {
397
+ // More padding for small graphs (few turns), less for large ones — prevents over-zooming
398
+ const padding = nodes.length <= 4 ? 0.35 : nodes.length <= 10 ? 0.2 : 0.12;
399
+ fitView({ padding, minZoom: 0.15, maxZoom: 0.75, duration: 300 });
400
+ }, 80);
401
+ return () => clearTimeout(timer);
402
+ }, [nodes.length > 0, fitView]); // eslint-disable-line react-hooks/exhaustive-deps
403
+
404
+ // Expose reset-view callback to parent
405
+ useEffect(() => {
406
+ if (onResetView) {
407
+ onResetView(() => {
408
+ fitView({ padding: 0.12, duration: 300 });
409
+ });
410
+ }
411
+ }, [fitView, onResetView]);
412
+
413
+ const onNodeClick = useCallback(
414
+ (_: React.MouseEvent, node: Node) => {
415
+ if (node.type === "turnNode") {
416
+ const d = node.data as TurnNodeData;
417
+ onScrollToMessage(d.messageIndex);
418
+ } else if (node.type === "subagentNode") {
419
+ const d = node.data as SubagentNodeData;
420
+ if (d.agentKey) onNavigateSession(d.agentKey);
421
+ }
422
+ },
423
+ [onScrollToMessage, onNavigateSession]
424
+ );
425
+
426
+ return (
427
+ <ReactFlow
428
+ nodes={nodes}
429
+ edges={edges}
430
+ nodeTypes={nodeTypes}
431
+ onNodeClick={onNodeClick}
432
+ minZoom={0.15}
433
+ maxZoom={1.5}
434
+ proOptions={{ hideAttribution: true }}
435
+ className="session-tree-flow"
436
+ />
437
+ );
438
+ }
439
+
440
+ // ── Main Export ──
441
+
442
+ export default function SessionTree({
443
+ messages,
444
+ sessionId,
445
+ sessionLabel: label,
446
+ allSessions,
447
+ onScrollToMessage,
448
+ onNavigateSession,
449
+ onClose,
450
+ }: SessionTreeProps) {
451
+ const resetViewRef = useRef<(() => void) | null>(null);
452
+
453
+ const handleResetView = useCallback((fn: () => void) => {
454
+ resetViewRef.current = fn;
455
+ }, []);
456
+
457
+ return (
458
+ <div className="tree-panel">
459
+ <div className="tree-panel-header">
460
+ <span className="tree-panel-title">Conversation Map</span>
461
+ <div className="tree-panel-actions">
462
+ <button
463
+ className="tree-panel-reset"
464
+ onClick={() => resetViewRef.current?.()}
465
+ title="Reset view"
466
+ >
467
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
468
+ <path
469
+ d="M2 8a6 6 0 0110.47-4M14 8a6 6 0 01-10.47 4"
470
+ stroke="currentColor"
471
+ strokeWidth="1.5"
472
+ strokeLinecap="round"
473
+ />
474
+ <path d="M12 1v3h-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
475
+ <path d="M4 15v-3h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
476
+ </svg>
477
+ </button>
478
+ <button className="tree-panel-close" onClick={onClose} title="Close">
479
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
480
+ <path
481
+ d="M3 3l10 10M13 3L3 13"
482
+ stroke="currentColor"
483
+ strokeWidth="2"
484
+ strokeLinecap="round"
485
+ />
486
+ </svg>
487
+ </button>
488
+ </div>
489
+ </div>
490
+ <div className="tree-panel-body">
491
+ <ReactFlowProvider key={sessionId}>
492
+ <InnerFlow
493
+ messages={messages}
494
+ sessionId={sessionId}
495
+ sessionLabel={label}
496
+ allSessions={allSessions}
497
+ onScrollToMessage={onScrollToMessage}
498
+ onNavigateSession={onNavigateSession}
499
+ onResetView={handleResetView}
500
+ />
501
+ </ReactFlowProvider>
502
+ </div>
503
+ </div>
504
+ );
505
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import { useStore } from "@/lib/store";
4
+ import { BlockColors, DEFAULT_BLOCK_COLORS } from "@/lib/types";
5
+
6
+ const BLOCK_COLOR_ENTRIES: { key: keyof BlockColors; label: string }[] = [
7
+ { key: "exec", label: "Exec / Bash" },
8
+ { key: "file", label: "File ops" },
9
+ { key: "web", label: "Web search/fetch" },
10
+ { key: "browser", label: "Browser" },
11
+ { key: "msg", label: "Message" },
12
+ { key: "agent", label: "Agent / Task" },
13
+ { key: "thinking", label: "Thinking" },
14
+ ];
15
+
16
+ const SystemIcon = () => (
17
+ <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
18
+ <rect x="1" y="2" width="14" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
19
+ <path d="M5 14h6M8 12v2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
20
+ </svg>
21
+ );
22
+
23
+ const DarkIcon = () => (
24
+ <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
25
+ <path d="M13.5 10.5A6 6 0 115.5 2.5a5 5 0 008 8z" fill="currentColor" />
26
+ </svg>
27
+ );
28
+
29
+ const LightIcon = () => (
30
+ <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
31
+ <circle cx="8" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.4" />
32
+ <path
33
+ d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.7 3.7l1.4 1.4M10.9 10.9l1.4 1.4M3.7 12.3l1.4-1.4M10.9 5.1l1.4-1.4"
34
+ stroke="currentColor"
35
+ strokeWidth="1.3"
36
+ strokeLinecap="round"
37
+ />
38
+ </svg>
39
+ );
40
+
41
+ const CloseIcon = () => (
42
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
43
+ <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
44
+ </svg>
45
+ );
46
+
47
+ export default function SettingsPanel() {
48
+ const theme = useStore((s) => s.theme);
49
+ const setTheme = useStore((s) => s.setTheme);
50
+ const blockColors = useStore((s) => s.blockColors);
51
+ const setBlockColor = useStore((s) => s.setBlockColor);
52
+ const resetBlockColor = useStore((s) => s.resetBlockColor);
53
+ const settings = useStore((s) => s.settings);
54
+ const setSetting = useStore((s) => s.setSetting);
55
+ const setSettingsOpen = useStore((s) => s.setSettingsOpen);
56
+
57
+ const themeButtons = [
58
+ { key: "system", icon: <SystemIcon />, label: "System" },
59
+ { key: "dark", icon: <DarkIcon />, label: "Dark" },
60
+ { key: "light", icon: <LightIcon />, label: "Light" },
61
+ ];
62
+
63
+ return (
64
+ <div className="settings-panel">
65
+ <div className="settings-header">
66
+ <span className="settings-title">Settings</span>
67
+ <button className="settings-close" onClick={() => setSettingsOpen(false)} title="Close">
68
+ <CloseIcon />
69
+ </button>
70
+ </div>
71
+
72
+ <div className="settings-body scroller">
73
+ {/* Theme section */}
74
+ <div className="settings-section">
75
+ <div className="settings-section-title">Theme</div>
76
+ <div className="settings-theme-row">
77
+ {themeButtons.map(({ key, icon, label }) => (
78
+ <button
79
+ key={key}
80
+ onClick={() => setTheme(key)}
81
+ className={`settings-theme-btn ${theme === key ? "active" : ""}`}
82
+ >
83
+ {icon}
84
+ <span>{label}</span>
85
+ </button>
86
+ ))}
87
+ </div>
88
+ </div>
89
+
90
+ {/* Block colors section */}
91
+ <div className="settings-section">
92
+ <div className="settings-section-title">Block colors</div>
93
+ <div className="settings-colors-list">
94
+ {BLOCK_COLOR_ENTRIES.map(({ key, label }) => (
95
+ <div key={key} className="settings-color-row">
96
+ <span className="settings-color-label">{label}</span>
97
+ <div
98
+ className="settings-color-swatch"
99
+ style={{ background: blockColors[key] }}
100
+ />
101
+ <input
102
+ type="color"
103
+ value={blockColors[key]}
104
+ onChange={(e) => setBlockColor(key, e.target.value)}
105
+ className="settings-color-input"
106
+ />
107
+ {blockColors[key] !== DEFAULT_BLOCK_COLORS[key] && (
108
+ <button
109
+ className="settings-color-reset"
110
+ onClick={() => resetBlockColor(key)}
111
+ title="Reset to default"
112
+ >
113
+ reset
114
+ </button>
115
+ )}
116
+ </div>
117
+ ))}
118
+ </div>
119
+ </div>
120
+
121
+ {/* Display section */}
122
+ <div className="settings-section">
123
+ <div className="settings-section-title">Display</div>
124
+ <label className="settings-toggle-row">
125
+ <input
126
+ type="checkbox"
127
+ checked={settings.showTimestamps}
128
+ onChange={(e) => setSetting("showTimestamps", e.target.checked)}
129
+ />
130
+ <span className="settings-toggle-label">Show timestamps</span>
131
+ </label>
132
+ <label className="settings-toggle-row">
133
+ <input
134
+ type="checkbox"
135
+ checked={settings.autoExpandToolCalls}
136
+ onChange={(e) => setSetting("autoExpandToolCalls", e.target.checked)}
137
+ />
138
+ <span className="settings-toggle-label">Auto-expand tool calls</span>
139
+ </label>
140
+ <label className="settings-toggle-row">
141
+ <input
142
+ type="checkbox"
143
+ checked={settings.compactSidebar}
144
+ onChange={(e) => setSetting("compactSidebar", e.target.checked)}
145
+ />
146
+ <span className="settings-toggle-label">Compact sidebar</span>
147
+ </label>
148
+ <label className="settings-toggle-row">
149
+ <input
150
+ type="checkbox"
151
+ checked={settings.skipPreamble}
152
+ onChange={(e) => setSetting("skipPreamble", e.target.checked)}
153
+ />
154
+ <span className="settings-toggle-label">Skip preamble (hide system messages before first user message)</span>
155
+ </label>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ );
160
+ }