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,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
2
+ <rect width="1024" height="1024" rx="200" fill="#0D0D0F"/>
3
+ <path d="M896 384c0 35.2-28.8 64-64 64s-64-28.8-64-64 28.8-64 64-64 64 28.8 64 64z M240 352c-62.4 0-112-49.6-112-112s49.6-112 112-112 112 49.6 112 112-49.6 112-112 112z M544 688c-80 0-144-64-144-144s64-144 144-144 144 64 144 144-64 144-144 144z M320 832c0-35.2 28.8-64 64-64s64 28.8 64 64-28.8 64-64 64-64-28.8-64-64z M176 656c0-17.6 14.4-32 32-32s32 14.4 32 32-14.4 32-32 32-32-14.4-32-32z M624 216c0-22.4 17.6-40 40-40s40 17.6 40 40-17.6 40-40 40-40-17.6-40-40z M736 560c0-27.2 20.8-48 48-48s48 20.8 48 48-20.8 48-48 48-48-20.8-48-48z" fill="#9B72EF"/>
4
+ </svg>
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from "next";
2
+ import "highlight.js/styles/base16/onedark.css";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "deep trace",
7
+ description: "Session browser for AI agent CLIs — Claude Code, Codex, OpenClaw, Kimi and more",
8
+ };
9
+
10
+ export default function RootLayout({
11
+ children,
12
+ }: Readonly<{
13
+ children: React.ReactNode;
14
+ }>) {
15
+ return (
16
+ <html lang="en">
17
+ <body>{children}</body>
18
+ </html>
19
+ );
20
+ }
@@ -0,0 +1,5 @@
1
+ import App from "@/components/App";
2
+
3
+ export default function Home() {
4
+ return <App />;
5
+ }
@@ -0,0 +1,393 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ interface AnalyticsData {
6
+ sessionsPerDay: { date: string; count: number; byProvider: Record<string, number> }[];
7
+ messagesPerDay: { date: string; count: number; byProvider: Record<string, number> }[];
8
+ providerBreakdown: { provider: string; count: number; sessions: number; messages: number; pct: number }[];
9
+ topTools: { name: string; count: number }[];
10
+ tokenTotals: { inputTokens: number; outputTokens: number; avgPerSession: number };
11
+ sessionLengthDist: { bucket: string; count: number }[];
12
+ totalSessions: number;
13
+ totalMessages: number;
14
+ avgSessionMessages: number;
15
+ hourOfDay: number[][];
16
+ }
17
+
18
+ const PROVIDER_COLORS: Record<string, string> = {
19
+ kova: "#9B72EF",
20
+ claude: "#3B82F6",
21
+ codex: "#F59E0B",
22
+ kimi: "#06B6D4",
23
+ gemini: "#22C55E",
24
+ copilot: "#52525B",
25
+ factory: "#F97316",
26
+ opencode: "#14B8A6",
27
+ aider: "#EC4899",
28
+ continue: "#8B5CF6",
29
+ cursor: "#6366F1",
30
+ };
31
+
32
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
33
+
34
+ function fmtNum(n: number): string {
35
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
36
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
37
+ return String(n);
38
+ }
39
+
40
+ function fmtTokens(n: number): string {
41
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
42
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
43
+ return String(n);
44
+ }
45
+
46
+ // ── Stat card ────────────────────────────────────────────
47
+ function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
48
+ return (
49
+ <div className="stat-card">
50
+ <div className="stat-label">{label}</div>
51
+ <div className="stat-value">{value}</div>
52
+ {sub && <div className="stat-sub">{sub}</div>}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ // ── Stacked bar chart (sessions or messages per day) ─────
58
+ function StackedBarChart({
59
+ data,
60
+ providers,
61
+ chartH = 140,
62
+ }: {
63
+ data: { date: string; count: number; byProvider: Record<string, number> }[];
64
+ providers: string[];
65
+ chartH?: number;
66
+ }) {
67
+ const maxVal = Math.max(...data.map((d) => d.count), 1);
68
+ const barW = Math.max(4, Math.min(16, Math.floor(560 / data.length) - 2));
69
+ const gap = Math.max(1, Math.floor(barW / 4));
70
+ const totalW = data.length * (barW + gap);
71
+
72
+ return (
73
+ <svg width="100%" viewBox={`0 0 ${totalW} ${chartH}`} preserveAspectRatio="none" style={{ display: "block", height: chartH }}>
74
+ {data.map((day, i) => {
75
+ const x = i * (barW + gap);
76
+ let yOffset = chartH;
77
+ return (
78
+ <g key={day.date}>
79
+ {providers.map((p) => {
80
+ const v = day.byProvider[p] || 0;
81
+ if (v === 0) return null;
82
+ const h = Math.max(1, (v / maxVal) * chartH);
83
+ yOffset -= h;
84
+ return (
85
+ <rect
86
+ key={p}
87
+ x={x}
88
+ y={yOffset}
89
+ width={barW}
90
+ height={h}
91
+ fill={PROVIDER_COLORS[p] || "#555"}
92
+ opacity={0.85}
93
+ >
94
+ <title>{day.date} · {p}: {v}</title>
95
+ </rect>
96
+ );
97
+ })}
98
+ {/* unfilled portion */}
99
+ {day.count === 0 && (
100
+ <rect x={x} y={chartH - 2} width={barW} height={2} fill="var(--border)" opacity={0.4} />
101
+ )}
102
+ </g>
103
+ );
104
+ })}
105
+ </svg>
106
+ );
107
+ }
108
+
109
+ // ── By-agent breakdown ───────────────────────────────────
110
+ function AgentBreakdown({
111
+ data,
112
+ metric,
113
+ }: {
114
+ data: { provider: string; count: number; sessions: number; messages: number; pct: number }[];
115
+ metric: "sessions" | "messages";
116
+ }) {
117
+ const maxVal = Math.max(...data.map((d) => (metric === "sessions" ? d.sessions : d.messages)), 1);
118
+ return (
119
+ <div className="agent-breakdown">
120
+ {data.map((row) => {
121
+ const val = metric === "sessions" ? row.sessions : row.messages;
122
+ const pct = Math.round((val / maxVal) * 100);
123
+ const color = PROVIDER_COLORS[row.provider] || "#555";
124
+ return (
125
+ <div key={row.provider} className="agent-breakdown-row">
126
+ <span className="agent-breakdown-name" style={{ color }}>
127
+ {row.provider}
128
+ </span>
129
+ <div className="agent-breakdown-bar-wrap">
130
+ <div
131
+ className="agent-breakdown-bar"
132
+ style={{ width: `${pct}%`, background: color }}
133
+ />
134
+ </div>
135
+ <span className="agent-breakdown-val">{fmtNum(val)}</span>
136
+ <span className="agent-breakdown-pct">{Math.round((val / maxVal) * 100)}%</span>
137
+ </div>
138
+ );
139
+ })}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // ── Time-of-day heatmap ───────────────────────────────────
145
+ function HeatmapGrid({ data }: { data: number[][] }) {
146
+ // data[weekday 0-6][hour 0-23]
147
+ const maxVal = Math.max(...data.flat(), 1);
148
+ const hours = Array.from({ length: 24 }, (_, i) => i);
149
+ return (
150
+ <div className="heatmap-wrap">
151
+ <div className="heatmap-hours-row">
152
+ <div className="heatmap-day-label" />
153
+ {hours.map((h) => (
154
+ <div key={h} className="heatmap-hour-label">
155
+ {h % 6 === 0 ? `${h}h` : ""}
156
+ </div>
157
+ ))}
158
+ </div>
159
+ {WEEKDAYS.map((day, wi) => (
160
+ <div key={day} className="heatmap-row">
161
+ <div className="heatmap-day-label">{day}</div>
162
+ {hours.map((h) => {
163
+ const val = data[wi][h] || 0;
164
+ const intensity = val / maxVal;
165
+ return (
166
+ <div
167
+ key={h}
168
+ className="heatmap-cell"
169
+ style={{
170
+ background: val === 0
171
+ ? "var(--raised)"
172
+ : `rgba(155, 114, 239, ${0.12 + intensity * 0.88})`,
173
+ }}
174
+ title={`${day} ${h}:00 — ${val} session${val !== 1 ? "s" : ""}`}
175
+ />
176
+ );
177
+ })}
178
+ </div>
179
+ ))}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ // ── Main dashboard ───────────────────────────────────────
185
+ export default function AnalyticsDashboard() {
186
+ const [data, setData] = useState<AnalyticsData | null>(null);
187
+ const [loading, setLoading] = useState(true);
188
+ const [error, setError] = useState<string | null>(null);
189
+ const [period, setPeriod] = useState<"7d" | "30d" | "90d" | "all">("30d");
190
+ const [agentFilter, setAgentFilter] = useState("all");
191
+ const [metric, setMetric] = useState<"sessions" | "messages">("sessions");
192
+
193
+ useEffect(() => {
194
+ setLoading(true);
195
+ setError(null);
196
+ fetch(`/api/analytics?period=${period}&agent=${agentFilter}`)
197
+ .then((r) => r.json())
198
+ .then((d: AnalyticsData) => { setData(d); setLoading(false); })
199
+ .catch((e) => { setError(String(e)); setLoading(false); });
200
+ }, [period, agentFilter]);
201
+
202
+ if (loading) return <div className="analytics-loading">Loading analytics…</div>;
203
+ if (error || !data) return <div className="analytics-loading">Failed to load analytics</div>;
204
+
205
+ const providers = data.providerBreakdown.map((p) => p.provider);
206
+ const chartData = metric === "sessions" ? data.sessionsPerDay : data.messagesPerDay;
207
+
208
+ // X-axis date labels (show every Nth)
209
+ const dateLabels = (() => {
210
+ const n = chartData.length;
211
+ const step = n <= 10 ? 1 : n <= 30 ? 5 : 10;
212
+ return chartData.map((d, i) => (i % step === 0 || i === n - 1 ? d.date.slice(5) : ""));
213
+ })();
214
+
215
+ return (
216
+ <div className="analytics-dash">
217
+
218
+ {/* Controls */}
219
+ <div className="analytics-controls">
220
+ <div className="analytics-filter-group">
221
+ {(["7d", "30d", "90d", "all"] as const).map((p) => (
222
+ <button
223
+ key={p}
224
+ className={`analytics-pill ${period === p ? "active" : ""}`}
225
+ onClick={() => setPeriod(p)}
226
+ >
227
+ {p === "all" ? "All time" : `Last ${p}`}
228
+ </button>
229
+ ))}
230
+ </div>
231
+ <select
232
+ className="analytics-agent-select"
233
+ value={agentFilter}
234
+ onChange={(e) => setAgentFilter(e.target.value)}
235
+ >
236
+ <option value="all">All agents</option>
237
+ {providers.map((p) => (
238
+ <option key={p} value={p}>{p}</option>
239
+ ))}
240
+ </select>
241
+ </div>
242
+
243
+ {/* Stat cards */}
244
+ <div className="stat-cards-row">
245
+ <StatCard
246
+ label="Sessions"
247
+ value={fmtNum(data.totalSessions)}
248
+ sub={`in ${period === "all" ? "all time" : `last ${period}`}`}
249
+ />
250
+ <StatCard
251
+ label="Messages"
252
+ value={fmtNum(data.totalMessages)}
253
+ sub="total across sessions"
254
+ />
255
+ <StatCard
256
+ label="Avg session"
257
+ value={`${data.avgSessionMessages} msgs`}
258
+ sub="messages per session"
259
+ />
260
+ <StatCard
261
+ label="Tokens used"
262
+ value={fmtTokens(data.tokenTotals.inputTokens + data.tokenTotals.outputTokens)}
263
+ sub={`${fmtTokens(data.tokenTotals.avgPerSession)} avg/session`}
264
+ />
265
+ </div>
266
+
267
+ {/* Sessions / messages over time */}
268
+ <div className="analytics-section">
269
+ <div className="analytics-section-header">
270
+ <span className="analytics-section-title">Activity over time</span>
271
+ <div className="analytics-filter-group small">
272
+ <button
273
+ className={`analytics-pill ${metric === "sessions" ? "active" : ""}`}
274
+ onClick={() => setMetric("sessions")}
275
+ >sessions</button>
276
+ <button
277
+ className={`analytics-pill ${metric === "messages" ? "active" : ""}`}
278
+ onClick={() => setMetric("messages")}
279
+ >messages</button>
280
+ </div>
281
+ </div>
282
+ {/* Provider color legend */}
283
+ <div className="provider-legend">
284
+ {providers.filter((p) => data.providerBreakdown.find(r => r.provider === p)!.count > 0).map((p) => (
285
+ <span key={p} className="legend-item">
286
+ <span className="legend-dot" style={{ background: PROVIDER_COLORS[p] || "#555" }} />
287
+ {p}
288
+ </span>
289
+ ))}
290
+ </div>
291
+ <div className="chart-area">
292
+ <StackedBarChart data={chartData} providers={providers} />
293
+ </div>
294
+ {/* X-axis labels */}
295
+ <div className="chart-x-labels">
296
+ {dateLabels.map((label, i) => (
297
+ <span key={i} className="chart-x-label">{label}</span>
298
+ ))}
299
+ </div>
300
+ </div>
301
+
302
+ {/* By-agent breakdown + time-of-day heatmap side by side */}
303
+ <div className="analytics-two-col">
304
+ <div className="analytics-section">
305
+ <div className="analytics-section-header">
306
+ <span className="analytics-section-title">By agent</span>
307
+ </div>
308
+ <AgentBreakdown data={data.providerBreakdown} metric={metric} />
309
+ </div>
310
+
311
+ <div className="analytics-section">
312
+ <div className="analytics-section-header">
313
+ <span className="analytics-section-title">Time of day</span>
314
+ <span className="analytics-section-hint">by session file time</span>
315
+ </div>
316
+ <HeatmapGrid data={data.hourOfDay} />
317
+ </div>
318
+ </div>
319
+
320
+ {/* Top tools */}
321
+ {data.topTools.length > 0 && (
322
+ <div className="analytics-section">
323
+ <div className="analytics-section-header">
324
+ <span className="analytics-section-title">Top tools (Claude Code)</span>
325
+ </div>
326
+ <div className="tool-bars">
327
+ {data.topTools.map((t, i) => {
328
+ const max = data.topTools[0].count || 1;
329
+ return (
330
+ <div key={t.name} className="tool-bar-row">
331
+ <span className="tool-rank">{i + 1}</span>
332
+ <span className="tool-name">{t.name}</span>
333
+ <div className="tool-bar-wrap">
334
+ <div className="tool-bar" style={{ width: `${(t.count / max) * 100}%` }} />
335
+ </div>
336
+ <span className="tool-count">{fmtNum(t.count)}</span>
337
+ </div>
338
+ );
339
+ })}
340
+ </div>
341
+ </div>
342
+ )}
343
+
344
+ {/* Token totals */}
345
+ <div className="analytics-section">
346
+ <div className="analytics-section-header">
347
+ <span className="analytics-section-title">Token usage</span>
348
+ </div>
349
+ <div className="token-grid">
350
+ <div className="token-cell">
351
+ <span className="token-label">Input</span>
352
+ <span className="token-value">{fmtTokens(data.tokenTotals.inputTokens)}</span>
353
+ </div>
354
+ <div className="token-cell">
355
+ <span className="token-label">Output</span>
356
+ <span className="token-value">{fmtTokens(data.tokenTotals.outputTokens)}</span>
357
+ </div>
358
+ <div className="token-cell">
359
+ <span className="token-label">Total</span>
360
+ <span className="token-value">{fmtTokens(data.tokenTotals.inputTokens + data.tokenTotals.outputTokens)}</span>
361
+ </div>
362
+ <div className="token-cell">
363
+ <span className="token-label">Avg / session</span>
364
+ <span className="token-value">{fmtTokens(data.tokenTotals.avgPerSession)}</span>
365
+ </div>
366
+ </div>
367
+ </div>
368
+
369
+ {/* Session length distribution */}
370
+ <div className="analytics-section">
371
+ <div className="analytics-section-header">
372
+ <span className="analytics-section-title">Session length distribution</span>
373
+ <span className="analytics-section-hint">by message count</span>
374
+ </div>
375
+ <div className="dist-bars">
376
+ {data.sessionLengthDist.map((row) => {
377
+ const max = Math.max(...data.sessionLengthDist.map((r) => r.count), 1);
378
+ return (
379
+ <div key={row.bucket} className="dist-row">
380
+ <span className="dist-label">{row.bucket}</span>
381
+ <div className="dist-bar-wrap">
382
+ <div className="dist-bar" style={{ width: `${(row.count / max) * 100}%` }} />
383
+ </div>
384
+ <span className="dist-count">{row.count}</span>
385
+ </div>
386
+ );
387
+ })}
388
+ </div>
389
+ </div>
390
+
391
+ </div>
392
+ );
393
+ }
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import { useEffect, useCallback, useRef, lazy, Suspense, useState } from "react";
4
+ import { useStore } from "@/lib/store";
5
+ import { useSSE } from "@/lib/useSSE";
6
+ import { sessionLabel } from "@/lib/client-utils";
7
+ import Sidebar from "./Sidebar";
8
+ import MainPanel from "./MainPanel";
9
+ import AnalyticsDashboard from "./AnalyticsDashboard";
10
+ import SetupView from "./SetupView";
11
+
12
+ const SessionTree = lazy(() => import("./SessionTree"));
13
+
14
+ export default function App() {
15
+ const setSessions = useStore((s) => s.setSessions);
16
+ const setCurrentSession = useStore((s) => s.setCurrentSession);
17
+ const currentSessionId = useStore((s) => s.currentSessionId);
18
+ const sessions = useStore((s) => s.sessions);
19
+ const currentMessages = useStore((s) => s.currentMessages);
20
+ const treePanelOpen = useStore((s) => s.treePanelOpen);
21
+ const setTreePanelOpen = useStore((s) => s.setTreePanelOpen);
22
+ const treePanelManualClose = useStore((s) => s.treePanelManualClose);
23
+ const setTreePanelManualClose = useStore((s) => s.setTreePanelManualClose);
24
+ const treePanelWidth = useStore((s) => s.treePanelWidth);
25
+ const setTreePanelWidth = useStore((s) => s.setTreePanelWidth);
26
+ const setTheme = useStore((s) => s.setTheme);
27
+ const initFromLocalStorage = useStore((s) => s.initFromLocalStorage);
28
+ const setScrollTargetIndex = useStore((s) => s.setScrollTargetIndex);
29
+ const activeSessions = useStore((s) => s.activeSessions);
30
+ const setMessages = useStore((s) => s.setMessages);
31
+ const sidebarTab = useStore((s) => s.sidebarTab);
32
+
33
+ const treeDragging = useRef(false);
34
+ const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
35
+ const [showSetup, setShowSetup] = useState<boolean | null>(null); // null = not yet checked
36
+
37
+ useEffect(() => {
38
+ const forceSetup = new URLSearchParams(window.location.search).get("setup") === "1";
39
+ const done = localStorage.getItem("llm-deep-trace-setup-done");
40
+ // Returning users (pre-date the setup screen) have other keys set — auto-skip
41
+ const isReturningUser = !!localStorage.getItem("llm-deep-trace-settings")
42
+ || !!localStorage.getItem("llm-deep-trace-block-colors");
43
+ if (isReturningUser && !done) {
44
+ localStorage.setItem("llm-deep-trace-setup-done", "1");
45
+ }
46
+ setShowSetup(forceSetup || (!done && !isReturningUser));
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ initFromLocalStorage();
51
+ try {
52
+ const saved = localStorage.getItem("deep-trace-theme") || "system";
53
+ setTheme(saved);
54
+ } catch {
55
+ setTheme("system");
56
+ }
57
+
58
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
59
+ const handler = () => {
60
+ const theme = useStore.getState().theme;
61
+ if (theme === "system") {
62
+ const root = document.documentElement;
63
+ root.classList.remove("theme-dim", "theme-light");
64
+ if (!mq.matches) root.classList.add("theme-light");
65
+ }
66
+ };
67
+ mq.addEventListener("change", handler);
68
+ return () => mq.removeEventListener("change", handler);
69
+ }, [setTheme, initFromLocalStorage]);
70
+
71
+ useEffect(() => {
72
+ fetch("/api/all-sessions")
73
+ .then((r) => r.json())
74
+ .then((data) => {
75
+ setSessions(data);
76
+ if (!currentSessionId && data.length) {
77
+ const main = data.find(
78
+ (s: { key: string }) => s.key === "agent:main:main"
79
+ );
80
+ setCurrentSession(main ? main.sessionId : data[0].sessionId);
81
+ }
82
+ })
83
+ .catch(() => {});
84
+ }, [setSessions, setCurrentSession, currentSessionId]);
85
+
86
+ useSSE();
87
+
88
+ // Live tail polling for active sessions
89
+ useEffect(() => {
90
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current);
91
+
92
+ if (currentSessionId && activeSessions.has(currentSessionId)) {
93
+ const sess = sessions.find(s => s.sessionId === currentSessionId);
94
+ const source = sess?.source || "kova";
95
+
96
+ pollTimerRef.current = setInterval(() => {
97
+ fetch(`/api/sessions/${currentSessionId}/messages?source=${source}`)
98
+ .then((r) => r.json())
99
+ .then((entries) => {
100
+ if (Array.isArray(entries)) setMessages(entries);
101
+ })
102
+ .catch(() => {});
103
+ }, 3000);
104
+ }
105
+
106
+ return () => {
107
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current);
108
+ };
109
+ }, [currentSessionId, activeSessions, sessions, setMessages]);
110
+
111
+ // Auto-show/hide tree panel based on subagents
112
+ useEffect(() => {
113
+ if (!currentSessionId) return;
114
+ const sess = sessions.find((s) => s.sessionId === currentSessionId);
115
+ if (!sess) return;
116
+
117
+ if (sess.hasSubagents) {
118
+ if (!treePanelManualClose) {
119
+ setTreePanelOpen(true);
120
+ }
121
+ } else {
122
+ setTreePanelOpen(false);
123
+ }
124
+ }, [currentSessionId, sessions, treePanelManualClose, setTreePanelOpen]);
125
+
126
+ // Reset manual close when switching sessions
127
+ useEffect(() => {
128
+ setTreePanelManualClose(false);
129
+ }, [currentSessionId, setTreePanelManualClose]);
130
+
131
+ const handleScrollToMessage = useCallback(
132
+ (messageIndex: number) => {
133
+ setScrollTargetIndex(messageIndex);
134
+ },
135
+ [setScrollTargetIndex]
136
+ );
137
+
138
+ const handleNavigateSession = useCallback(
139
+ (keyOrId: string) => {
140
+ if (!keyOrId) return;
141
+ const target = sessions.find(
142
+ (s) =>
143
+ s.sessionId === keyOrId ||
144
+ s.key === keyOrId ||
145
+ s.sessionId.startsWith(keyOrId) ||
146
+ s.sessionId.includes(keyOrId) ||
147
+ s.key.endsWith("/" + keyOrId.slice(0, 8)) ||
148
+ ("agent-" + keyOrId) === s.sessionId
149
+ );
150
+ if (target) {
151
+ setCurrentSession(target.sessionId);
152
+ }
153
+ },
154
+ [sessions, setCurrentSession]
155
+ );
156
+
157
+ const handleCloseTree = useCallback(() => {
158
+ setTreePanelOpen(false);
159
+ setTreePanelManualClose(true);
160
+ }, [setTreePanelOpen, setTreePanelManualClose]);
161
+
162
+ const handleToggleTree = useCallback(() => {
163
+ if (treePanelOpen) {
164
+ handleCloseTree();
165
+ } else {
166
+ setTreePanelOpen(true);
167
+ setTreePanelManualClose(false);
168
+ }
169
+ }, [treePanelOpen, handleCloseTree, setTreePanelOpen, setTreePanelManualClose]);
170
+
171
+ // Tree panel drag-to-resize
172
+ const handleTreeMouseDown = useCallback((e: React.MouseEvent) => {
173
+ e.preventDefault();
174
+ treeDragging.current = true;
175
+ document.body.style.cursor = "col-resize";
176
+ document.body.style.userSelect = "none";
177
+
178
+ const startX = e.clientX;
179
+ const startW = treePanelWidth;
180
+
181
+ const onMove = (ev: MouseEvent) => {
182
+ if (!treeDragging.current) return;
183
+ const newW = startW - (ev.clientX - startX);
184
+ setTreePanelWidth(newW);
185
+ };
186
+
187
+ const onUp = () => {
188
+ treeDragging.current = false;
189
+ document.body.style.cursor = "";
190
+ document.body.style.userSelect = "";
191
+ document.removeEventListener("mousemove", onMove);
192
+ document.removeEventListener("mouseup", onUp);
193
+ };
194
+
195
+ document.addEventListener("mousemove", onMove);
196
+ document.addEventListener("mouseup", onUp);
197
+ }, [treePanelWidth, setTreePanelWidth]);
198
+
199
+ const sess = sessions.find((s) => s.sessionId === currentSessionId);
200
+ const sessLabel = sess
201
+ ? sessionLabel(sess)
202
+ : currentSessionId
203
+ ? currentSessionId.slice(0, 14) + "\u2026"
204
+ : "Session";
205
+
206
+ // Show setup on first run (null = still checking localStorage, avoid flash)
207
+ if (showSetup === null) return null;
208
+ if (showSetup) return <SetupView onDone={() => setShowSetup(false)} />;
209
+
210
+ return (
211
+ <div className="app-shell">
212
+ <Sidebar />
213
+ {sidebarTab === "analytics" ? <AnalyticsDashboard /> : <MainPanel />}
214
+ {/* Right pane tray handle */}
215
+ <div
216
+ className={`tray-handle ${treePanelOpen ? "open" : ""}`}
217
+ onClick={handleToggleTree}
218
+ title={treePanelOpen ? "Close conversation map" : "Open conversation map"}
219
+ >
220
+ <svg width="6" height="24" viewBox="0 0 6 24" fill="none">
221
+ <rect x="1" y="8" width="1.5" height="8" rx="0.75" fill="currentColor" />
222
+ <rect x="3.5" y="8" width="1.5" height="8" rx="0.75" fill="currentColor" />
223
+ </svg>
224
+ </div>
225
+ {treePanelOpen && currentSessionId && (
226
+ <div className="tree-panel-wrap" style={{ width: treePanelWidth }}>
227
+ <div className="tree-resize-handle" onMouseDown={handleTreeMouseDown} />
228
+ <Suspense fallback={<div className="tree-panel"><div className="loading-state"><div className="spinner" />Loading&hellip;</div></div>}>
229
+ <SessionTree
230
+ messages={currentMessages}
231
+ sessionId={currentSessionId}
232
+ sessionLabel={sessLabel}
233
+ allSessions={sessions}
234
+ onScrollToMessage={handleScrollToMessage}
235
+ onNavigateSession={handleNavigateSession}
236
+ onClose={handleCloseTree}
237
+ />
238
+ </Suspense>
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
+ }