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,1128 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
4
+ import { useStore, BlockCategory } from "@/lib/store";
5
+ import { BlockColors, NormalizedMessage, SessionInfo } from "@/lib/types";
6
+ import { sessionLabel, relativeTime, channelIcon, copyToClipboard, extractText, extractResultText } from "@/lib/client-utils";
7
+ import MessageRenderer from "./MessageRenderer";
8
+
9
+ function MsgSearchBar({
10
+ visible,
11
+ onClose,
12
+ }: {
13
+ visible: boolean;
14
+ onClose: () => void;
15
+ }) {
16
+ const [query, setQuery] = useState("");
17
+ const [matches, setMatches] = useState<Element[]>([]);
18
+ const [currentIdx, setCurrentIdx] = useState(-1);
19
+ const inputRef = useRef<HTMLInputElement>(null);
20
+ const messagesEl = useRef<HTMLElement | null>(null);
21
+
22
+ useEffect(() => {
23
+ messagesEl.current = document.getElementById("messages-container");
24
+ }, []);
25
+
26
+ const clearHighlights = useCallback(() => {
27
+ const el = messagesEl.current;
28
+ if (!el) return;
29
+ el.querySelectorAll("mark.search-hl").forEach((mark) => {
30
+ const parent = mark.parentNode;
31
+ if (parent) {
32
+ parent.replaceChild(document.createTextNode(mark.textContent || ""), mark);
33
+ parent.normalize();
34
+ }
35
+ });
36
+ }, []);
37
+
38
+ const doSearch = useCallback(() => {
39
+ clearHighlights();
40
+ const el = messagesEl.current;
41
+ if (!el || !query.trim()) {
42
+ setMatches([]);
43
+ setCurrentIdx(-1);
44
+ return;
45
+ }
46
+
47
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+ const regex = new RegExp(escaped, "gi");
49
+ const newMatches: Element[] = [];
50
+
51
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
52
+ const nodesToProcess: Text[] = [];
53
+ while (walker.nextNode()) {
54
+ const node = walker.currentNode as Text;
55
+ if (node.parentElement?.closest("script,button")) continue;
56
+ if (regex.test(node.textContent || "")) nodesToProcess.push(node);
57
+ regex.lastIndex = 0;
58
+ }
59
+
60
+ for (const node of nodesToProcess) {
61
+ const parent = node.parentNode;
62
+ if (!parent) continue;
63
+ const frag = document.createDocumentFragment();
64
+ let lastIdx = 0;
65
+ let match;
66
+ regex.lastIndex = 0;
67
+ const text = node.textContent || "";
68
+ while ((match = regex.exec(text)) !== null) {
69
+ if (match.index > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, match.index)));
70
+ const mark = document.createElement("mark");
71
+ mark.className = "search-hl";
72
+ mark.textContent = match[0];
73
+ newMatches.push(mark);
74
+ frag.appendChild(mark);
75
+ lastIdx = regex.lastIndex;
76
+ }
77
+ if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
78
+ parent.replaceChild(frag, node);
79
+ }
80
+
81
+ setMatches(newMatches);
82
+ if (newMatches.length > 0) {
83
+ setCurrentIdx(0);
84
+ newMatches[0].classList.add("current");
85
+ newMatches[0].scrollIntoView({ behavior: "smooth", block: "center" });
86
+ } else {
87
+ setCurrentIdx(-1);
88
+ }
89
+ }, [query, clearHighlights]);
90
+
91
+ useEffect(() => {
92
+ const timer = setTimeout(doSearch, 200);
93
+ return () => clearTimeout(timer);
94
+ }, [query, doSearch]);
95
+
96
+ const navigate = useCallback(
97
+ (dir: number) => {
98
+ if (!matches.length) return;
99
+ matches.forEach((m) => m.classList.remove("current"));
100
+ const newIdx = (currentIdx + dir + matches.length) % matches.length;
101
+ setCurrentIdx(newIdx);
102
+ matches[newIdx].classList.add("current");
103
+ matches[newIdx].scrollIntoView({ behavior: "smooth", block: "center" });
104
+ },
105
+ [matches, currentIdx]
106
+ );
107
+
108
+ const handleClose = useCallback(() => {
109
+ clearHighlights();
110
+ setQuery("");
111
+ setMatches([]);
112
+ setCurrentIdx(-1);
113
+ onClose();
114
+ }, [clearHighlights, onClose]);
115
+
116
+ if (!visible) return null;
117
+
118
+ return (
119
+ <div className="msg-search-bar">
120
+ <input
121
+ ref={inputRef}
122
+ type="text"
123
+ placeholder="Search in session\u2026"
124
+ autoComplete="off"
125
+ spellCheck={false}
126
+ value={query}
127
+ onChange={(e) => setQuery(e.target.value)}
128
+ onKeyDown={(e) => {
129
+ if (e.key === "Enter") { e.shiftKey ? navigate(-1) : navigate(1); e.preventDefault(); }
130
+ if (e.key === "Escape") { handleClose(); e.preventDefault(); }
131
+ }}
132
+ className="msg-search-input"
133
+ autoFocus
134
+ />
135
+ <span className="msg-search-count">
136
+ {currentIdx >= 0 ? currentIdx + 1 : 0} / {matches.length}
137
+ </span>
138
+ <button onClick={() => navigate(-1)} title="Previous (Shift+Enter)" className="msg-search-btn">
139
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M4 10l4-4 4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
140
+ </button>
141
+ <button onClick={() => navigate(1)} title="Next (Enter)" className="msg-search-btn">
142
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
143
+ </button>
144
+ <button onClick={handleClose} title="Close (Esc)" className="msg-search-btn">
145
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
146
+ </button>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ const BLOCK_PILLS: { category: BlockCategory; label: string }[] = [
152
+ { category: "user-msg", label: "user" },
153
+ { category: "asst-text", label: "assistant" },
154
+ { category: "thinking", label: "thinking" },
155
+ { category: "exec", label: "exec" },
156
+ { category: "file", label: "edit / write" },
157
+ { category: "web", label: "web" },
158
+ { category: "browser", label: "browser" },
159
+ { category: "msg", label: "message" },
160
+ { category: "agent", label: "tasks" },
161
+ ];
162
+
163
+ // Three states per block type: "collapsed" | "expanded" | "hidden"
164
+ // Click cycles: collapsed → expanded → hidden → collapsed
165
+ function BlockToggleToolbar({
166
+ blockExpansion,
167
+ blockColors,
168
+ onToggle,
169
+ hiddenBlockTypes,
170
+ onToggleHidden,
171
+ }: {
172
+ blockExpansion: Record<string, boolean>;
173
+ blockColors: BlockColors;
174
+ onToggle: (category: BlockCategory) => void;
175
+ hiddenBlockTypes: Set<BlockCategory>;
176
+ onToggleHidden: (category: BlockCategory) => void;
177
+ }) {
178
+ const handleCycle = (category: BlockCategory) => {
179
+ // conversation-turn categories: two-state (visible ↔ hidden)
180
+ if (category === "user-msg" || category === "asst-text") {
181
+ onToggleHidden(category);
182
+ return;
183
+ }
184
+ const hidden = hiddenBlockTypes.has(category);
185
+ const expanded = blockExpansion[category];
186
+ if (hidden) {
187
+ // hidden → collapsed (make visible, ensure collapsed)
188
+ onToggleHidden(category);
189
+ if (expanded) onToggle(category); // force collapsed
190
+ } else if (!expanded) {
191
+ // collapsed → expanded
192
+ onToggle(category);
193
+ } else {
194
+ // expanded → hidden
195
+ onToggle(category); // collapse first
196
+ onToggleHidden(category); // then hide
197
+ }
198
+ };
199
+
200
+ return (
201
+ <div className="block-toggle-strip">
202
+ <span className="block-strip-label">blocks</span>
203
+ {BLOCK_PILLS.map(({ category, label }, pillIdx) => {
204
+ // Visual separator between conversation pills and tool block pills
205
+ const separator = pillIdx === 2 ? <span key="sep" className="block-pill-sep" /> : null;
206
+ const hidden = hiddenBlockTypes.has(category);
207
+ const expanded = !hidden && blockExpansion[category];
208
+ const color = blockColors[category] || "#888899";
209
+ // state: "hidden" | "collapsed" | "expanded"
210
+ const state = hidden ? "hidden" : expanded ? "expanded" : "collapsed";
211
+ const isTwoState = category === "user-msg" || category === "asst-text";
212
+ const titles = isTwoState ? {
213
+ collapsed: `${label} — visible. Click to hide.`,
214
+ expanded: `${label} — visible. Click to hide.`,
215
+ hidden: `${label} — hidden. Click to show.`,
216
+ } : {
217
+ collapsed: `${label} — visible, collapsed. Click to expand.`,
218
+ expanded: `${label} — visible, expanded. Click to hide.`,
219
+ hidden: `${label} — hidden. Click to show.`,
220
+ };
221
+ return (
222
+ <React.Fragment key={category}>
223
+ {separator}
224
+ <button
225
+ className={`block-pill block-pill-tri state-${state}`}
226
+ style={
227
+ state === "expanded"
228
+ ? { background: color, borderColor: color, color: "#fff" }
229
+ : state === "collapsed"
230
+ ? { borderColor: color, color }
231
+ : { borderColor: "var(--border)", color: "var(--text-3)" }
232
+ }
233
+ title={titles[state]}
234
+ onClick={() => handleCycle(category)}
235
+ >
236
+ {state === "hidden" && (
237
+ <svg width="9" height="9" viewBox="0 0 16 16" fill="none" style={{ marginRight: 3, opacity: 0.5 }}>
238
+ <path d="M2 2l12 12M6.5 3.5C7 3.2 7.5 3 8 3c4 0 6 5 6 5s-.7 1.4-2 2.8M4.2 4.7C2.8 6.1 2 8 2 8s2 5 6 5c1.4 0 2.6-.5 3.5-1.2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
239
+ </svg>
240
+ )}
241
+ <span style={state === "hidden" ? { opacity: 0.45 } : undefined}>{label}</span>
242
+ </button>
243
+ </React.Fragment>
244
+ );
245
+ })}
246
+ </div>
247
+ );
248
+ }
249
+
250
+ // ── Pinned Messages Strip ──
251
+ function PinnedStrip({ pinnedIndices, messages, onUnpin, onScrollTo }: {
252
+ pinnedIndices: number[];
253
+ messages: NormalizedMessage[];
254
+ onUnpin: (idx: number) => void;
255
+ onScrollTo: (idx: number) => void;
256
+ }) {
257
+ const [open, setOpen] = useState(true);
258
+ const sorted = [...pinnedIndices].sort((a, b) => a - b);
259
+ return (
260
+ <div className="pinned-strip">
261
+ <div className="pinned-strip-header" onClick={() => setOpen(!open)}>
262
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" style={{ marginRight: 5 }}>
263
+ <path d="M5 17H19V13L17 5H7L5 13V17Z" fill="#9B72EF" stroke="#9B72EF" strokeWidth="0.8" strokeLinejoin="round"/>
264
+ <line x1="5" y1="9" x2="19" y2="9" stroke="#7B52CF" strokeWidth="1.2" strokeLinecap="round"/>
265
+ <line x1="12" y1="17" x2="12" y2="22" stroke="#9B72EF" strokeWidth="1.6" strokeLinecap="round"/>
266
+ </svg>
267
+ <span>{sorted.length} pinned</span>
268
+ <span className="pinned-strip-chevron">{open ? "▲" : "▼"}</span>
269
+ </div>
270
+ {open && (
271
+ <div className="pinned-strip-items">
272
+ {sorted.map((idx) => {
273
+ const msg = messages[idx];
274
+ const preview = (() => {
275
+ if (!msg) return `message ${idx}`;
276
+ const role = msg.message?.role || msg.type || "";
277
+ const content = msg.message?.content;
278
+ if (typeof content === "string") return content.slice(0, 80);
279
+ if (Array.isArray(content)) {
280
+ for (const b of content as unknown as Record<string, unknown>[]) {
281
+ if (b.type === "text" && typeof b.text === "string") return b.text.slice(0, 80);
282
+ }
283
+ }
284
+ return `${role} message`;
285
+ })();
286
+ return (
287
+ <div key={idx} className="pinned-strip-item" onClick={() => onScrollTo(idx)}>
288
+ <span className="pinned-preview">{preview}</span>
289
+ <button className="pinned-unpin" title="Unpin" onClick={(e) => { e.stopPropagation(); onUnpin(idx); }}>×</button>
290
+ </div>
291
+ );
292
+ })}
293
+ </div>
294
+ )}
295
+ </div>
296
+ );
297
+ }
298
+
299
+ // ── Export helpers ──
300
+ function entryToMarkdown(entry: NormalizedMessage, hiddenBlockTypes: Set<BlockCategory>): string {
301
+ const msg = entry.message;
302
+ if (!msg) return "";
303
+ const role = msg.role;
304
+ const content = msg.content;
305
+
306
+ if (role === "user") {
307
+ if (hiddenBlockTypes.has("user-msg")) return "";
308
+ const text = typeof content === "string" ? content
309
+ : Array.isArray(content) ? (content as unknown as Record<string, unknown>[])
310
+ .filter(b => b.type === "text").map(b => b.text as string).join("\n")
311
+ : "";
312
+ return `**User**\n\n${text}\n\n`;
313
+ }
314
+
315
+ if (role === "assistant") {
316
+ const lines: string[] = [];
317
+ if (Array.isArray(content)) {
318
+ for (const block of content as unknown as Record<string, unknown>[]) {
319
+ if (block.type === "thinking" && block.thinking) {
320
+ if (!hiddenBlockTypes.has("thinking")) {
321
+ lines.push(`> *thinking*\n>\n> ${String(block.thinking).replace(/\n/g, "\n> ")}\n`);
322
+ }
323
+ } else if (block.type === "text" && block.text) {
324
+ if (!hiddenBlockTypes.has("asst-text")) lines.push(String(block.text));
325
+ } else if (block.type === "tool_use" || block.type === "toolCall") {
326
+ const catKey = (() => {
327
+ const name = (block.name as string) || "";
328
+ if (/bash|exec|run_command/i.test(name)) return "exec";
329
+ if (/read|write|edit|str_replace|create_file/i.test(name)) return "file";
330
+ if (/web_search|brave|search/i.test(name)) return "web";
331
+ if (/web_fetch|fetch_page/i.test(name)) return "web";
332
+ if (/browser/i.test(name)) return "browser";
333
+ if (/message|telegram|send/i.test(name)) return "msg";
334
+ if (/task|spawn|sessions_spawn/i.test(name)) return "agent";
335
+ return null;
336
+ })() as BlockCategory | null;
337
+ if (!catKey || !hiddenBlockTypes.has(catKey)) {
338
+ const inputStr = JSON.stringify(block.input || {}, null, 2);
339
+ lines.push(`**Tool:** \`${block.name}\`\n\`\`\`json\n${inputStr}\n\`\`\``);
340
+ }
341
+ }
342
+ }
343
+ } else if (typeof content === "string") {
344
+ if (!hiddenBlockTypes.has("asst-text")) lines.push(content);
345
+ }
346
+ if (!lines.length) return "";
347
+ return `**Assistant**\n\n${lines.join("\n\n")}\n\n`;
348
+ }
349
+
350
+ if (role === "toolResult") {
351
+ const catKey = msg.toolName ? (() => {
352
+ const n = msg.toolName!;
353
+ if (/bash|exec/i.test(n)) return "exec";
354
+ if (/read|write|edit/i.test(n)) return "file";
355
+ if (/web|search/i.test(n)) return "web";
356
+ if (/browser/i.test(n)) return "browser";
357
+ if (/message|telegram/i.test(n)) return "msg";
358
+ if (/task|spawn/i.test(n)) return "agent";
359
+ return null;
360
+ })() as BlockCategory | null : null;
361
+ if (catKey && hiddenBlockTypes.has(catKey)) return "";
362
+ const text = typeof content === "string" ? content
363
+ : Array.isArray(content) ? (content as unknown as Record<string, unknown>[])
364
+ .filter(b => b.type === "text").map(b => b.text as string).join("\n")
365
+ : "";
366
+ return `> **Result** (${msg.toolName || "tool"})\n>\n> ${text.slice(0, 500).replace(/\n/g, "\n> ")}${text.length > 500 ? "\n> *[truncated]*" : ""}\n\n`;
367
+ }
368
+
369
+ return "";
370
+ }
371
+
372
+ function exportSession(messages: NormalizedMessage[], hiddenBlockTypes: Set<BlockCategory>, format: "markdown" | "json") {
373
+ if (format === "json") {
374
+ const visible = messages.filter(e => {
375
+ if (e.type === "compaction" || e.type === "model_change") return true;
376
+ const msg = e.message;
377
+ if (!msg) return false;
378
+ if (msg.role === "user" && hiddenBlockTypes.has("user-msg")) return false;
379
+ return true;
380
+ });
381
+ return JSON.stringify(visible, null, 2);
382
+ }
383
+ // markdown
384
+ const parts: string[] = [];
385
+ for (const entry of messages) {
386
+ if (entry.type === "compaction") { parts.push("---\n*[context compacted]*\n---\n\n"); continue; }
387
+ if (entry.type === "model_change") { parts.push(`---\n*[model: ${entry.modelId}]*\n\n`); continue; }
388
+ if (entry.type !== "message") continue;
389
+ const line = entryToMarkdown(entry, hiddenBlockTypes);
390
+ if (line) parts.push(line);
391
+ }
392
+ return parts.join("");
393
+ }
394
+
395
+ function ExportButton({ messages, hiddenBlockTypes }: { messages: NormalizedMessage[]; hiddenBlockTypes: Set<BlockCategory> }) {
396
+ const [open, setOpen] = useState(false);
397
+ const ref = useRef<HTMLDivElement>(null);
398
+
399
+ useEffect(() => {
400
+ if (!open) return;
401
+ const handler = (e: MouseEvent) => {
402
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
403
+ };
404
+ document.addEventListener("mousedown", handler);
405
+ return () => document.removeEventListener("mousedown", handler);
406
+ }, [open]);
407
+
408
+ const doExport = (format: "markdown" | "json") => {
409
+ const text = exportSession(messages, hiddenBlockTypes, format);
410
+ navigator.clipboard.writeText(text).catch(() => {});
411
+ setOpen(false);
412
+ };
413
+
414
+ return (
415
+ <div className="export-btn-wrap" ref={ref}>
416
+ <button className="panel-icon-btn" title="Export visible session" onClick={() => setOpen(!open)}>
417
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
418
+ <path d="M8 2v8M5 7l3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
419
+ <path d="M2 11v1.5A1.5 1.5 0 003.5 14h9a1.5 1.5 0 001.5-1.5V11" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
420
+ </svg>
421
+ </button>
422
+ {open && (
423
+ <div className="export-dropdown">
424
+ <button className="export-option" onClick={() => doExport("markdown")}>
425
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
426
+ <path d="M2 3h12M2 7h8M2 11h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
427
+ </svg>
428
+ Copy as Markdown
429
+ </button>
430
+ <button className="export-option" onClick={() => doExport("json")}>
431
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
432
+ <path d="M4 2H2.5A1.5 1.5 0 001 3.5v2A1.5 1.5 0 002.5 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
433
+ <path d="M4 14H2.5A1.5 1.5 0 011 12.5v-2A1.5 1.5 0 012.5 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
434
+ <path d="M12 2h1.5A1.5 1.5 0 0115 3.5v2A1.5 1.5 0 0113.5 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
435
+ <path d="M12 14h1.5A1.5 1.5 0 0015 12.5v-2A1.5 1.5 0 0013.5 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
436
+ <circle cx="8" cy="8" r="1.2" fill="currentColor"/>
437
+ </svg>
438
+ Copy as JSON
439
+ </button>
440
+ <div className="export-note">respects current block filters</div>
441
+ </div>
442
+ )}
443
+ </div>
444
+ );
445
+ }
446
+
447
+ // ── LLM Ref Popover ──
448
+ function LlmRefPopover({ filePath, onClose }: { filePath: string; onClose: () => void }) {
449
+ const ref = useRef<HTMLDivElement>(null);
450
+ useEffect(() => {
451
+ const handler = (e: MouseEvent) => {
452
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
453
+ };
454
+ document.addEventListener("mousedown", handler);
455
+ return () => document.removeEventListener("mousedown", handler);
456
+ }, [onClose]);
457
+
458
+ const claudeCmd = `claude --context "${filePath}" ""`;
459
+
460
+ return (
461
+ <div className="llm-ref-popover" ref={ref}>
462
+ <div className="llm-ref-path">{filePath}</div>
463
+ <div className="llm-ref-actions">
464
+ <button className="llm-ref-btn" onClick={() => { copyToClipboard(filePath); onClose(); }}>Copy path</button>
465
+ <button className="llm-ref-btn" onClick={() => { copyToClipboard(claudeCmd); onClose(); }}>Copy claude command</button>
466
+ </div>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ // ── Session Stats ──
472
+ function SessionStatsBar({ messages, sess }: { messages: NormalizedMessage[]; sess: SessionInfo | undefined }) {
473
+ const [expanded, setExpanded] = useState(false);
474
+
475
+ const stats = useMemo(() => {
476
+ let userCount = 0;
477
+ let assistantCount = 0;
478
+ const toolCounts: Record<string, number> = {};
479
+ let firstTs = "";
480
+ let lastTs = "";
481
+ let spawns = 0;
482
+ let tokenEstimate = 0;
483
+
484
+ for (const m of messages) {
485
+ if (m.timestamp) {
486
+ if (!firstTs) firstTs = m.timestamp;
487
+ lastTs = m.timestamp;
488
+ }
489
+ if (m.message?.role === "user" && m.type !== "tool_result" && !m.message.toolCallId) userCount++;
490
+ if (m.message?.role === "assistant") {
491
+ assistantCount++;
492
+ if (Array.isArray(m.message.content)) {
493
+ for (const block of m.message.content as unknown as Record<string, unknown>[]) {
494
+ if (block.type === "tool_use") {
495
+ const name = (block.name as string) || "unknown";
496
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
497
+ if (name === "sessions_spawn" || name === "Task" || name === "task") spawns++;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ if (m.message?.role === "toolResult") {
503
+ const name = m.message.toolName || "unknown";
504
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
505
+ }
506
+ // Estimate tokens from _usage events or text length
507
+ if ((m as unknown as Record<string, unknown>).usage) {
508
+ const usage = (m as unknown as Record<string, unknown>).usage as Record<string, number>;
509
+ tokenEstimate += (usage.input_tokens || 0) + (usage.output_tokens || 0);
510
+ }
511
+ }
512
+
513
+ const duration = firstTs && lastTs
514
+ ? Math.max(0, new Date(lastTs).getTime() - new Date(firstTs).getTime())
515
+ : 0;
516
+
517
+ const topTools = Object.entries(toolCounts)
518
+ .sort((a, b) => b[1] - a[1])
519
+ .slice(0, 3);
520
+
521
+ const totalToolCalls = Object.values(toolCounts).reduce((s, v) => s + v, 0);
522
+
523
+ return { userCount, assistantCount, totalToolCalls, topTools, toolCounts, duration, spawns, tokenEstimate };
524
+ }, [messages]);
525
+
526
+ const fmtDuration = (ms: number) => {
527
+ if (ms < 60000) return Math.round(ms / 1000) + "s";
528
+ if (ms < 3600000) return Math.round(ms / 60000) + "m";
529
+ const h = Math.floor(ms / 3600000);
530
+ const m = Math.round((ms % 3600000) / 60000);
531
+ return `${h}h ${m}m`;
532
+ };
533
+
534
+ return (
535
+ <div className="stats-bar">
536
+ <div className="stats-summary" onClick={() => setExpanded(!expanded)}>
537
+ <span className="stats-item" title="Messages">
538
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M2 3h12v8H4l-2 2V3z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round"/></svg>
539
+ {stats.userCount + stats.assistantCount}
540
+ </span>
541
+ <span className="stats-item" title="Tool calls">
542
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M9.5 2.5l4 4-7 7-4 0 0-4 7-7z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round"/></svg>
543
+ {stats.totalToolCalls}
544
+ {stats.topTools.length > 0 && (
545
+ <span className="stats-tools-inline">
546
+ ({stats.topTools.map(([n, c]) => `${n}:${c}`).join(", ")})
547
+ </span>
548
+ )}
549
+ </span>
550
+ {stats.duration > 0 && (
551
+ <span className="stats-item" title="Duration">
552
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.3"/><path d="M8 5v3l2 2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>
553
+ {fmtDuration(stats.duration)}
554
+ </span>
555
+ )}
556
+ {stats.tokenEstimate > 0 && (
557
+ <span className="stats-item" title="Estimated tokens">
558
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M4 8h8M6 5h4M5 11h6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>
559
+ {stats.tokenEstimate.toLocaleString()} tok
560
+ </span>
561
+ )}
562
+ {stats.spawns > 0 && (
563
+ <span className="stats-item" title="Subagent spawns">
564
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="4" r="2" stroke="currentColor" strokeWidth="1.2"/><path d="M4 14v-2a4 4 0 018 0v2" stroke="currentColor" strokeWidth="1.2"/></svg>
565
+ {stats.spawns} spawns
566
+ </span>
567
+ )}
568
+ <span className="stats-expand-hint">{expanded ? "collapse" : "expand"}</span>
569
+ </div>
570
+ {expanded && (
571
+ <div className="stats-detail">
572
+ <div className="stats-detail-row"><span>User messages</span><span>{stats.userCount}</span></div>
573
+ <div className="stats-detail-row"><span>Assistant messages</span><span>{stats.assistantCount}</span></div>
574
+ <div className="stats-detail-row"><span>Total tool calls</span><span>{stats.totalToolCalls}</span></div>
575
+ {Object.entries(stats.toolCounts).sort((a, b) => b[1] - a[1]).map(([name, count]) => (
576
+ <div key={name} className="stats-detail-row sub"><span>{name}</span><span>{count}</span></div>
577
+ ))}
578
+ {stats.duration > 0 && <div className="stats-detail-row"><span>Duration</span><span>{fmtDuration(stats.duration)}</span></div>}
579
+ {stats.spawns > 0 && <div className="stats-detail-row"><span>Subagent spawns</span><span>{stats.spawns}</span></div>}
580
+ {stats.tokenEstimate > 0 && <div className="stats-detail-row"><span>Estimated tokens</span><span>{stats.tokenEstimate.toLocaleString()}</span></div>}
581
+ </div>
582
+ )}
583
+ </div>
584
+ );
585
+ }
586
+
587
+ // ── Error detection ──
588
+ function useErrorInfo(messages: NormalizedMessage[]) {
589
+ return useMemo(() => {
590
+ const errors: { index: number; toolName: string }[] = [];
591
+ for (let i = 0; i < messages.length; i++) {
592
+ const m = messages[i];
593
+ if (m.message?.role === "toolResult" && m.message.isError) {
594
+ errors.push({ index: i, toolName: m.message.toolName || "tool" });
595
+ }
596
+ }
597
+ return errors;
598
+ }, [messages]);
599
+ }
600
+
601
+ // ── Export helpers ──
602
+ function formatSessionMarkdown(messages: NormalizedMessage[], sess: SessionInfo | undefined): string {
603
+ const lines: string[] = [];
604
+ const title = sess ? sessionLabel(sess) : "Session";
605
+ const provider = sess?.source || "unknown";
606
+ const date = sess?.lastUpdated ? new Date(sess.lastUpdated).toLocaleDateString() : "unknown";
607
+ const msgCount = messages.filter(m => m.message?.role === "user" || m.message?.role === "assistant").length;
608
+
609
+ lines.push(`# Session: ${title}`);
610
+ lines.push(`Provider: ${provider} | Date: ${date} | Messages: ${msgCount}`);
611
+ lines.push("");
612
+ lines.push("---");
613
+ lines.push("");
614
+
615
+ for (const m of messages) {
616
+ if (m.type === "compaction") {
617
+ lines.push("*[context compacted]*");
618
+ lines.push("");
619
+ continue;
620
+ }
621
+ if (m.type !== "message" || !m.message) continue;
622
+
623
+ const role = m.message.role;
624
+ if (role === "user") {
625
+ const text = extractText(m.message.content);
626
+ if (text) {
627
+ lines.push(`**User:** ${text}`);
628
+ lines.push("");
629
+ }
630
+ } else if (role === "assistant") {
631
+ const text = extractText(m.message.content);
632
+ if (text) {
633
+ lines.push(`**Assistant:** ${text}`);
634
+ lines.push("");
635
+ }
636
+ // Include tool calls
637
+ if (Array.isArray(m.message.content)) {
638
+ for (const block of m.message.content as unknown as Record<string, unknown>[]) {
639
+ if (block.type === "tool_use") {
640
+ const name = (block.name as string) || "tool";
641
+ const input = (block.input || {}) as Record<string, unknown>;
642
+ const cmd = (input.command as string) || (input.file_path as string) || (input.query as string) || "";
643
+ if (cmd) {
644
+ lines.push(`> **${name}:** ${cmd}`);
645
+ } else {
646
+ lines.push(`> **${name}**`);
647
+ }
648
+ }
649
+ }
650
+ lines.push("");
651
+ }
652
+ } else if (role === "toolResult") {
653
+ const toolName = m.message.toolName || "tool";
654
+ const text = extractResultText(m.message.content);
655
+ const isError = m.message.isError;
656
+ const preview = text.slice(0, 200).replace(/\n/g, "\n> ");
657
+ if (isError) {
658
+ lines.push(`> **${toolName} error:**`);
659
+ } else {
660
+ lines.push(`> **${toolName} result:**`);
661
+ }
662
+ if (preview) lines.push(`> ${preview}${text.length > 200 ? "..." : ""}`);
663
+ lines.push("");
664
+ }
665
+
666
+ lines.push("---");
667
+ lines.push("");
668
+ }
669
+
670
+ return lines.join("\n");
671
+ }
672
+
673
+ function ExportDropdown({ messages, sess, onClose }: { messages: NormalizedMessage[]; sess: SessionInfo | undefined; onClose: () => void }) {
674
+ const ref = useRef<HTMLDivElement>(null);
675
+ useEffect(() => {
676
+ const handler = (e: MouseEvent) => {
677
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
678
+ };
679
+ document.addEventListener("mousedown", handler);
680
+ return () => document.removeEventListener("mousedown", handler);
681
+ }, [onClose]);
682
+
683
+ const md = useMemo(() => formatSessionMarkdown(messages, sess), [messages, sess]);
684
+ const title = sess ? sessionLabel(sess) : "session";
685
+ const filename = title.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60) + ".md";
686
+
687
+ const handleDownload = () => {
688
+ const blob = new Blob([md], { type: "text/markdown" });
689
+ const url = URL.createObjectURL(blob);
690
+ const a = document.createElement("a");
691
+ a.href = url;
692
+ a.download = filename;
693
+ a.click();
694
+ URL.revokeObjectURL(url);
695
+ onClose();
696
+ };
697
+
698
+ const handleCopy = () => {
699
+ copyToClipboard(md);
700
+ onClose();
701
+ };
702
+
703
+ return (
704
+ <div className="export-dropdown" ref={ref}>
705
+ <button className="export-option" onClick={handleDownload}>
706
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M8 2v8M4 8l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 13h12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
707
+ Export as Markdown
708
+ </button>
709
+ <button className="export-option" onClick={handleCopy}>
710
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.3"/><path d="M3 11V3h8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
711
+ Copy all as text
712
+ </button>
713
+ </div>
714
+ );
715
+ }
716
+
717
+ export default function MainPanel() {
718
+ const currentSessionId = useStore((s) => s.currentSessionId);
719
+ const sessions = useStore((s) => s.sessions);
720
+ const currentMessages = useStore((s) => s.currentMessages);
721
+ const loading = useStore((s) => s.loading);
722
+ const sseConnected = useStore((s) => s.sseConnected);
723
+ const allThinkingExpanded = useStore((s) => s.allThinkingExpanded);
724
+ const toggleAllThinking = useStore((s) => s.toggleAllThinking);
725
+ const blockExpansion = useStore((s) => s.blockExpansion);
726
+ const toggleBlockExpansion = useStore((s) => s.toggleBlockExpansion);
727
+ const setCurrentSession = useStore((s) => s.setCurrentSession);
728
+ const setMessages = useStore((s) => s.setMessages);
729
+ const setLoading = useStore((s) => s.setLoading);
730
+ const blockColors = useStore((s) => s.blockColors);
731
+ const appSettings = useStore((s) => s.settings);
732
+ const scrollTargetIndex = useStore((s) => s.scrollTargetIndex);
733
+ const setScrollTargetIndex = useStore((s) => s.setScrollTargetIndex);
734
+ const activeSessions = useStore((s) => s.activeSessions);
735
+ const hiddenBlockTypes = useStore((s) => s.hiddenBlockTypes);
736
+ const toggleHiddenBlockType = useStore((s) => s.toggleHiddenBlockType);
737
+ const pinnedMessages = useStore((s) => s.pinnedMessages);
738
+ const togglePinMessage = useStore((s) => s.togglePinMessage);
739
+
740
+ const messagesRef = useRef<HTMLDivElement>(null);
741
+ const [searchVisible, setSearchVisible] = useState(false);
742
+ const [displayCount, setDisplayCount] = useState(100);
743
+ const loadingMoreRef = useRef(false);
744
+ const [refPopoverOpen, setRefPopoverOpen] = useState(false);
745
+ const [exportOpen, setExportOpen] = useState(false);
746
+
747
+ const sess = sessions.find((s) => s.sessionId === currentSessionId);
748
+ const errors = useErrorInfo(currentMessages);
749
+
750
+ // Must be declared before any early return — Rules of Hooks
751
+ const displayMessages = useMemo(() => {
752
+ if (!appSettings.skipPreamble) return currentMessages;
753
+ let firstUserIdx = -1;
754
+ for (let i = 0; i < currentMessages.length; i++) {
755
+ const role = currentMessages[i].message?.role;
756
+ if (role === "user") { firstUserIdx = i; break; }
757
+ }
758
+ if (firstUserIdx <= 0) return currentMessages;
759
+ return currentMessages.slice(firstUserIdx);
760
+ }, [currentMessages, appSettings.skipPreamble]);
761
+
762
+ // Build tool inputs map: toolCallId → input args (so tool results can access original inputs)
763
+ const toolInputsMap = useMemo(() => {
764
+ const map = new Map<string, Record<string, unknown>>();
765
+ for (const msg of currentMessages) {
766
+ if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) {
767
+ for (const block of msg.message.content as unknown as Record<string, unknown>[]) {
768
+ if ((block.type === "tool_use" || block.type === "toolCall") && block.id) {
769
+ map.set(
770
+ block.id as string,
771
+ (block.input || block.arguments || {}) as Record<string, unknown>
772
+ );
773
+ }
774
+ }
775
+ }
776
+ }
777
+ return map;
778
+ }, [currentMessages]);
779
+
780
+ // Navigate to a child/subagent session by key or ID (supports partial ID matching)
781
+ const handleNavigateToSession = useCallback(
782
+ (keyOrId: string) => {
783
+ if (!keyOrId) return;
784
+ const target = sessions.find(
785
+ (s) =>
786
+ s.sessionId === keyOrId ||
787
+ s.key === keyOrId ||
788
+ s.sessionId.startsWith(keyOrId) ||
789
+ s.key.endsWith("/" + keyOrId.slice(0, 8))
790
+ );
791
+ if (target) {
792
+ setCurrentSession(target.sessionId);
793
+ }
794
+ },
795
+ [sessions, setCurrentSession]
796
+ );
797
+
798
+ // Load messages when session changes
799
+ useEffect(() => {
800
+ if (!currentSessionId) return;
801
+ setLoading(true);
802
+ setDisplayCount(100);
803
+
804
+ const source = sess?.source || "kova";
805
+ fetch(`/api/sessions/${currentSessionId}/messages?source=${source}`)
806
+ .then((res) => res.json())
807
+ .then((entries) => {
808
+ if (!Array.isArray(entries)) {
809
+ setMessages([]);
810
+ } else {
811
+ setMessages(entries);
812
+ }
813
+ setLoading(false);
814
+ setTimeout(() => {
815
+ if (messagesRef.current) {
816
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
817
+ }
818
+ }, 50);
819
+ })
820
+ .catch(() => {
821
+ setMessages([]);
822
+ setLoading(false);
823
+ });
824
+ }, [currentSessionId, sess?.source, setMessages, setLoading]);
825
+
826
+ // Infinite scroll: load previous 50 messages when scrolling to top
827
+ const handleScroll = useCallback(() => {
828
+ const el = messagesRef.current;
829
+ if (!el || loadingMoreRef.current) return;
830
+
831
+ const total = currentMessages.length;
832
+ const startIdx = Math.max(0, total - displayCount);
833
+
834
+ if (el.scrollTop <= 10 && startIdx > 0) {
835
+ loadingMoreRef.current = true;
836
+ const prevScrollHeight = el.scrollHeight;
837
+
838
+ setDisplayCount((prev) => prev + 50);
839
+
840
+ // Preserve scroll position after loading more
841
+ requestAnimationFrame(() => {
842
+ const newScrollHeight = el.scrollHeight;
843
+ el.scrollTop = newScrollHeight - prevScrollHeight;
844
+ loadingMoreRef.current = false;
845
+ });
846
+ }
847
+ }, [currentMessages.length, displayCount]);
848
+
849
+ useEffect(() => {
850
+ const el = messagesRef.current;
851
+ if (!el) return;
852
+ el.addEventListener("scroll", handleScroll, { passive: true });
853
+ return () => el.removeEventListener("scroll", handleScroll);
854
+ }, [handleScroll]);
855
+
856
+ // Keyboard shortcut for search
857
+ useEffect(() => {
858
+ const handleKey = (e: KeyboardEvent) => {
859
+ const mod = e.metaKey || e.ctrlKey;
860
+ if (mod && e.key === "f" && currentSessionId) {
861
+ e.preventDefault();
862
+ setSearchVisible(true);
863
+ }
864
+ if (e.key === "Escape" && searchVisible) {
865
+ setSearchVisible(false);
866
+ }
867
+ };
868
+ document.addEventListener("keydown", handleKey);
869
+ return () => document.removeEventListener("keydown", handleKey);
870
+ }, [currentSessionId, searchVisible]);
871
+
872
+ // Arrow key navigation
873
+ useEffect(() => {
874
+ const handleKey = (e: KeyboardEvent) => {
875
+ const mod = e.metaKey || e.ctrlKey;
876
+ if (mod) return;
877
+ const active = document.activeElement;
878
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) return;
879
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
880
+ e.preventDefault();
881
+ const curr = useStore.getState().filteredSessions;
882
+ let idx = curr.findIndex((s) => s.sessionId === useStore.getState().currentSessionId);
883
+ idx += e.key === "ArrowDown" ? 1 : -1;
884
+ if (idx < 0) idx = 0;
885
+ if (idx >= curr.length) idx = curr.length - 1;
886
+ if (curr[idx]) setCurrentSession(curr[idx].sessionId);
887
+ }
888
+ };
889
+ document.addEventListener("keydown", handleKey);
890
+ return () => document.removeEventListener("keydown", handleKey);
891
+ }, [setCurrentSession]);
892
+
893
+ // Scroll to message when scrollTargetIndex is set (from tree panel)
894
+ useEffect(() => {
895
+ if (scrollTargetIndex === null) return;
896
+ // Ensure the target message is in the visible range
897
+ const total = currentMessages.length;
898
+ const needed = total - scrollTargetIndex;
899
+ if (needed > displayCount) {
900
+ setDisplayCount(needed + 10);
901
+ }
902
+ // Defer scroll to allow render
903
+ requestAnimationFrame(() => {
904
+ const container = messagesRef.current;
905
+ if (!container) return;
906
+ const el = container.querySelector(
907
+ `[data-msg-index="${scrollTargetIndex}"]`
908
+ );
909
+ if (el) {
910
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
911
+ // Flash highlight
912
+ el.classList.add("msg-scroll-highlight");
913
+ setTimeout(() => el.classList.remove("msg-scroll-highlight"), 1500);
914
+ }
915
+ });
916
+ setScrollTargetIndex(null);
917
+ }, [scrollTargetIndex, currentMessages.length, displayCount, setScrollTargetIndex]);
918
+
919
+ if (!currentSessionId) {
920
+ return (
921
+ <div className="empty-state" style={{ background: "var(--bg)" }}>
922
+ <div className="empty-icon">
923
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
924
+ <rect x="8" y="2" width="8" height="4" rx="1" stroke="currentColor" strokeWidth="1.5" />
925
+ <rect x="4" y="5" width="16" height="17" rx="2" stroke="currentColor" strokeWidth="1.5" />
926
+ <line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
927
+ <line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
928
+ </svg>
929
+ </div>
930
+ <div className="empty-title">No session selected</div>
931
+ <div className="empty-hint">Use &uarr;&darr; to navigate &middot; &#8984;K to search</div>
932
+ </div>
933
+ );
934
+ }
935
+
936
+ const label = sess ? sessionLabel(sess) : currentSessionId.slice(0, 14) + "\u2026";
937
+
938
+ const total = displayMessages.length;
939
+ const startIdx = Math.max(0, total - displayCount);
940
+ const visible = displayMessages.slice(startIdx);
941
+
942
+ return (
943
+ <div className="main-panel">
944
+ {/* Header */}
945
+ <div className="main-header">
946
+ <div className="main-header-top">
947
+ <span className="main-session-label">{label}</span>
948
+ <span
949
+ className="main-session-id"
950
+ title="Click to copy session ID"
951
+ onClick={() => copyToClipboard(currentSessionId, "Session ID copied")}
952
+ >
953
+ {currentSessionId.slice(0, 16)}&hellip;
954
+ </span>
955
+ {sess?.channel && (
956
+ <span className="main-meta">
957
+ <span dangerouslySetInnerHTML={{ __html: channelIcon(sess.channel) }} />{" "}
958
+ <b>{sess.channel}</b>
959
+ </span>
960
+ )}
961
+ <span className="main-meta">
962
+ updated <b>{relativeTime(sess?.lastUpdated || 0)}</b>
963
+ </span>
964
+ {sess?.compactionCount ? (
965
+ <span className="main-meta">
966
+ &middot; <b>{sess.compactionCount} compactions</b>
967
+ </span>
968
+ ) : null}
969
+ <span className="main-spacer" />
970
+ {/* Jump to error badge */}
971
+ {errors.length > 0 && (
972
+ <button
973
+ className="error-badge-btn"
974
+ title={`${errors.length} error${errors.length !== 1 ? "s" : ""} — click to jump`}
975
+ onClick={() => {
976
+ const idx = errors[0].index;
977
+ const total = currentMessages.length;
978
+ const needed = total - idx;
979
+ if (needed > displayCount) setDisplayCount(needed + 10);
980
+ requestAnimationFrame(() => {
981
+ const container = messagesRef.current;
982
+ if (!container) return;
983
+ const el = container.querySelector(`[data-msg-index="${idx}"]`);
984
+ if (el) {
985
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
986
+ el.classList.add("msg-scroll-highlight");
987
+ setTimeout(() => el.classList.remove("msg-scroll-highlight"), 1500);
988
+ }
989
+ });
990
+ }}
991
+ >
992
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 13H1L8 1z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round"/><path d="M8 6v3M8 11v1" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
993
+ {errors.length} error{errors.length !== 1 ? "s" : ""}
994
+ </button>
995
+ )}
996
+ {/* LLM Ref button */}
997
+ {sess?.filePath && (
998
+ <div style={{ position: "relative" }}>
999
+ <button
1000
+ className={`toolbar-btn ${refPopoverOpen ? "active" : ""}`}
1001
+ onClick={() => setRefPopoverOpen(!refPopoverOpen)}
1002
+ title="Session file reference"
1003
+ >
1004
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 2h6l4 4v8H4V2z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round"/><path d="M10 2v4h4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>
1005
+ </button>
1006
+ {refPopoverOpen && <LlmRefPopover filePath={sess.filePath} onClose={() => setRefPopoverOpen(false)} />}
1007
+ </div>
1008
+ )}
1009
+ {/* Export button */}
1010
+ <div style={{ position: "relative" }}>
1011
+ <button
1012
+ className={`toolbar-btn ${exportOpen ? "active" : ""}`}
1013
+ onClick={() => setExportOpen(!exportOpen)}
1014
+ title="Export session"
1015
+ >
1016
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M8 2v8M4 8l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 13h12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
1017
+ </button>
1018
+ {exportOpen && <ExportDropdown messages={currentMessages} sess={sess} onClose={() => setExportOpen(false)} />}
1019
+ </div>
1020
+ <div
1021
+ className="live-indicator"
1022
+ style={{ color: sseConnected ? "var(--green)" : "var(--red)" }}
1023
+ >
1024
+ <div
1025
+ className="live-dot"
1026
+ style={{ background: sseConnected ? "var(--green)" : "var(--red)" }}
1027
+ />
1028
+ {currentSessionId && activeSessions.has(currentSessionId)
1029
+ ? "tailing"
1030
+ : sseConnected ? "connected" : "offline"}
1031
+ </div>
1032
+ </div>
1033
+ <div className="main-toolbar">
1034
+ <button
1035
+ className={`toolbar-btn ${searchVisible ? "active" : ""}`}
1036
+ onClick={() => setSearchVisible(!searchVisible)}
1037
+ >
1038
+ search &#8984;F
1039
+ </button>
1040
+ <ExportButton messages={currentMessages} hiddenBlockTypes={hiddenBlockTypes} />
1041
+ </div>
1042
+ </div>
1043
+
1044
+ {/* Block toggle toolbar */}
1045
+ <BlockToggleToolbar
1046
+ blockExpansion={blockExpansion}
1047
+ blockColors={blockColors}
1048
+ onToggle={toggleBlockExpansion}
1049
+ hiddenBlockTypes={hiddenBlockTypes}
1050
+ onToggleHidden={toggleHiddenBlockType}
1051
+ />
1052
+
1053
+ {/* Session stats */}
1054
+ {currentSessionId && currentMessages.length > 0 && (
1055
+ <SessionStatsBar messages={currentMessages} sess={sess} />
1056
+ )}
1057
+
1058
+ {/* Search bar */}
1059
+ <MsgSearchBar visible={searchVisible} onClose={() => setSearchVisible(false)} />
1060
+
1061
+ {/* Pinned messages strip */}
1062
+ {currentSessionId && (pinnedMessages[currentSessionId]?.length ?? 0) > 0 && (
1063
+ <PinnedStrip
1064
+ pinnedIndices={pinnedMessages[currentSessionId]}
1065
+ messages={currentMessages}
1066
+ onUnpin={(idx) => togglePinMessage(currentSessionId, idx)}
1067
+ onScrollTo={(idx) => {
1068
+ const total = currentMessages.length;
1069
+ const needed = total - idx;
1070
+ if (needed > displayCount) setDisplayCount(needed + 10);
1071
+ requestAnimationFrame(() => {
1072
+ const el = messagesRef.current?.querySelector(`[data-msg-index="${idx}"]`);
1073
+ if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); }
1074
+ });
1075
+ }}
1076
+ />
1077
+ )}
1078
+
1079
+ {/* Messages */}
1080
+ <div
1081
+ id="messages-container"
1082
+ ref={messagesRef}
1083
+ className="messages-thread scroller"
1084
+ >
1085
+ <div className="messages-inner">
1086
+ {loading ? (
1087
+ <div className="loading-state">
1088
+ <div className="spinner" />
1089
+ Loading&hellip;
1090
+ </div>
1091
+ ) : currentMessages.length === 0 ? (
1092
+ <div className="loading-state">No messages</div>
1093
+ ) : (
1094
+ visible.map((entry, i) => {
1095
+ const absIdx = startIdx + i;
1096
+ const pinned = currentSessionId ? (pinnedMessages[currentSessionId] || []).includes(absIdx) : false;
1097
+ return (
1098
+ <div key={absIdx} data-msg-index={absIdx} className={`msg-wrap ${pinned ? "msg-pinned" : ""}`}>
1099
+ <button
1100
+ className={`pin-btn ${pinned ? "active" : ""}`}
1101
+ title={pinned ? "Unpin message" : "Pin message"}
1102
+ onClick={() => currentSessionId && togglePinMessage(currentSessionId, absIdx)}
1103
+ >
1104
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none">
1105
+ <path d="M5 17H19V13L17 5H7L5 13V17Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" fill={pinned ? "currentColor" : "none"}/>
1106
+ <line x1="5" y1="9" x2="19" y2="9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
1107
+ <line x1="12" y1="17" x2="12" y2="22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
1108
+ </svg>
1109
+ </button>
1110
+ <MessageRenderer
1111
+ entry={entry}
1112
+ allThinkingExpanded={allThinkingExpanded}
1113
+ blockExpansion={blockExpansion}
1114
+ blockColors={blockColors}
1115
+ settings={appSettings}
1116
+ toolInputsMap={toolInputsMap}
1117
+ onNavigateSession={handleNavigateToSession}
1118
+ hiddenBlockTypes={hiddenBlockTypes}
1119
+ />
1120
+ </div>
1121
+ );
1122
+ })
1123
+ )}
1124
+ </div>
1125
+ </div>
1126
+ </div>
1127
+ );
1128
+ }