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,714 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, useCallback, useState } from "react";
4
+ import { useStore } from "@/lib/store";
5
+ import { sessionLabel, relativeTime, cleanPreview, copyToClipboard } from "@/lib/client-utils";
6
+ import { SessionInfo } from "@/lib/types";
7
+ import SettingsPanel from "./SettingsPanel";
8
+ import Logo from "./Logo";
9
+
10
+ const ChevronSvg = () => (
11
+ <svg width="9" height="9" viewBox="0 0 16 16" fill="none">
12
+ <path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
13
+ </svg>
14
+ );
15
+
16
+ const SearchIcon = () => (
17
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
18
+ <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
19
+ <line x1="11" y1="11" x2="14.5" y2="14.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
20
+ </svg>
21
+ );
22
+
23
+ const GearIcon = () => (
24
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
25
+ <circle cx="8" cy="8" r="2.5" stroke="currentColor" strokeWidth="1.3" />
26
+ <path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
27
+ </svg>
28
+ );
29
+
30
+ const sourceColors: Record<string, string> = {
31
+ kova: "#9B72EF",
32
+ claude: "#3B82F6",
33
+ codex: "#F59E0B",
34
+ kimi: "#06B6D4",
35
+ gemini: "#22C55E",
36
+ copilot: "#52525B",
37
+ factory: "#F97316",
38
+ opencode: "#14B8A6",
39
+ aider: "#EC4899",
40
+ continue: "#8B5CF6",
41
+ cursor: "#6366F1",
42
+ };
43
+
44
+ const BotSvg = () => (
45
+ <svg viewBox="0 0 14 14" width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg" className="bot-icon">
46
+ <rect x="2" y="5" width="10" height="7" rx="1.5" stroke="#22C55E" strokeWidth="1.4"/>
47
+ <rect x="5" y="2" width="4" height="3" rx="1" stroke="#22C55E" strokeWidth="1.4"/>
48
+ <circle cx="5" cy="8.5" r="1" fill="#22C55E"/>
49
+ <circle cx="9" cy="8.5" r="1" fill="#22C55E"/>
50
+ </svg>
51
+ );
52
+
53
+ function ContextMenu({
54
+ x,
55
+ y,
56
+ session,
57
+ isArchived,
58
+ onClose,
59
+ }: {
60
+ x: number;
61
+ y: number;
62
+ session: SessionInfo;
63
+ isArchived: boolean;
64
+ onClose: () => void;
65
+ }) {
66
+ const archiveSession = useStore((s) => s.archiveSession);
67
+ const unarchiveSession = useStore((s) => s.unarchiveSession);
68
+ const starredSessionIds = useStore((s) => s.starredSessionIds);
69
+ const toggleStarred = useStore((s) => s.toggleStarred);
70
+ const ref = useRef<HTMLDivElement>(null);
71
+
72
+ useEffect(() => {
73
+ const handler = (e: MouseEvent) => {
74
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
75
+ };
76
+ document.addEventListener("mousedown", handler);
77
+ return () => document.removeEventListener("mousedown", handler);
78
+ }, [onClose]);
79
+
80
+ return (
81
+ <div
82
+ ref={ref}
83
+ className="ctx-menu"
84
+ style={{ top: y, left: x }}
85
+ >
86
+ {isArchived ? (
87
+ <button
88
+ className="ctx-menu-item"
89
+ onClick={() => { unarchiveSession(session.sessionId); onClose(); }}
90
+ >
91
+ Unarchive
92
+ </button>
93
+ ) : (
94
+ <button
95
+ className="ctx-menu-item"
96
+ onClick={() => { archiveSession(session.sessionId); onClose(); }}
97
+ >
98
+ Archive
99
+ </button>
100
+ )}
101
+ <button
102
+ className="ctx-menu-item"
103
+ onClick={() => { copyToClipboard(session.sessionId, "Session ID copied"); onClose(); }}
104
+ >
105
+ Copy ID
106
+ </button>
107
+ {session.filePath && (
108
+ <button
109
+ className="ctx-menu-item"
110
+ onClick={() => { copyToClipboard(session.filePath!, "Path copied"); onClose(); }}
111
+ >
112
+ Copy path
113
+ </button>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ function SessionItem({
120
+ session,
121
+ isSubagent,
122
+ childCount,
123
+ hasSubagents,
124
+ isExpanded,
125
+ isSelected,
126
+ isLive,
127
+ isArchived,
128
+ isStarred,
129
+ compact,
130
+ onSelect,
131
+ onToggleExpand,
132
+ onContextMenu,
133
+ onToggleStar,
134
+ }: {
135
+ session: SessionInfo;
136
+ isSubagent: boolean;
137
+ childCount: number;
138
+ hasSubagents: boolean;
139
+ isExpanded: boolean;
140
+ isSelected: boolean;
141
+ isLive: boolean;
142
+ isArchived: boolean;
143
+ isStarred: boolean;
144
+ compact: boolean;
145
+ onSelect: (id: string) => void;
146
+ onToggleExpand: (id: string) => void;
147
+ onContextMenu: (e: React.MouseEvent, session: SessionInfo) => void;
148
+ onToggleStar: (id: string) => void;
149
+ }) {
150
+ const label = sessionLabel(session);
151
+ const src = session.source || "kova";
152
+ const dotCls = isLive
153
+ ? "live"
154
+ : isSubagent
155
+ ? "subagent"
156
+ : session.isActive && !session.isDeleted
157
+ ? "active"
158
+ : "inactive";
159
+ const preview = cleanPreview(session.preview || "");
160
+
161
+ let cls = "session-item";
162
+ if (isSelected) cls += " selected";
163
+ if (isSubagent) cls += " subagent";
164
+ if (compact) cls += " compact";
165
+ if (isArchived) cls += " archived";
166
+
167
+ return (
168
+ <div
169
+ className={cls}
170
+ onClick={() => {
171
+ onSelect(session.sessionId);
172
+ if (childCount > 0) onToggleExpand(session.sessionId);
173
+ }}
174
+ onContextMenu={(e) => onContextMenu(e, session)}
175
+ >
176
+ <div className="session-row">
177
+ {childCount > 0 && (
178
+ <button
179
+ className="expand-btn"
180
+ onClick={(e) => {
181
+ e.stopPropagation();
182
+ onToggleExpand(session.sessionId);
183
+ }}
184
+ title={`${isExpanded ? "Collapse" : "Expand"} ${childCount} subagent${childCount !== 1 ? "s" : ""}`}
185
+ >
186
+ <span className={`expand-chevron ${isExpanded ? "open" : ""}`}>
187
+ <ChevronSvg />
188
+ </span>
189
+ </button>
190
+ )}
191
+ <div className={`session-dot ${dotCls}`} />
192
+ <span className={`session-label ${isSubagent ? "sub" : ""}`}>
193
+ {label}
194
+ </span>
195
+ {isLive && <span className="live-badge-small">live</span>}
196
+ {hasSubagents && <BotSvg />}
197
+ {isSubagent && <span className="subagent-badge">subagent</span>}
198
+ <button
199
+ className={`star-btn ${isStarred ? "starred" : ""}`}
200
+ title={isStarred ? "Unstar" : "Star session"}
201
+ onClick={(e) => { e.stopPropagation(); onToggleStar(session.sessionId); }}
202
+ >
203
+ <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
204
+ {isStarred
205
+ ? <path d="M8 1l1.8 3.6 4 .6-2.9 2.8.7 4L8 10l-3.6 1.9.7-4L2.2 5.2l4-.6z" fill="#F59E0B"/>
206
+ : <path d="M8 1l1.8 3.6 4 .6-2.9 2.8.7 4L8 10l-3.6 1.9.7-4L2.2 5.2l4-.6z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"/>
207
+ }
208
+ </svg>
209
+ </button>
210
+ <span className="session-time">
211
+ {relativeTime(session.lastUpdated)}
212
+ </span>
213
+ </div>
214
+ {!compact && (
215
+ <div className="session-meta">
216
+ <span className="session-source" style={{ color: sourceColors[src] || "var(--text-2)" }}>
217
+ {src}
218
+ </span>
219
+ <span className="session-msgs">
220
+ {session.messageCount || 0} msgs
221
+ </span>
222
+ {session.compactionCount > 0 && (
223
+ <span className="session-msgs">
224
+ &middot; {session.compactionCount}&times;
225
+ </span>
226
+ )}
227
+ {childCount > 0 && (
228
+ <span className="subagent-count-badge">
229
+ ({childCount} subagent{childCount !== 1 ? "s" : ""})
230
+ </span>
231
+ )}
232
+ </div>
233
+ )}
234
+ {!compact && preview && (
235
+ <div className="session-preview">{preview}</div>
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ export default function Sidebar() {
242
+ const sessions = useStore((s) => s.sessions);
243
+ const filteredSessions = useStore((s) => s.filteredSessions);
244
+ const currentSessionId = useStore((s) => s.currentSessionId);
245
+ const searchQuery = useStore((s) => s.searchQuery);
246
+ const sourceFilters = useStore((s) => s.sourceFilters);
247
+ const expandedGroups = useStore((s) => s.expandedGroups);
248
+ const expandAllGroups = useStore((s) => s.expandAllGroups);
249
+ const collapseAllGroups = useStore((s) => s.collapseAllGroups);
250
+ const sidebarWidth = useStore((s) => s.sidebarWidth);
251
+ const settingsOpen = useStore((s) => s.settingsOpen);
252
+ const compactSidebar = useStore((s) => s.settings.compactSidebar);
253
+ const sidebarTab = useStore((s) => s.sidebarTab);
254
+ const activeSessions = useStore((s) => s.activeSessions);
255
+ const archivedSessionIds = useStore((s) => s.archivedSessionIds);
256
+ const starredSessionIds = useStore((s) => s.starredSessionIds);
257
+ const toggleStarred = useStore((s) => s.toggleStarred);
258
+ const pinnedMessages = useStore((s) => s.pinnedMessages);
259
+ const setSearchQuery = useStore((s) => s.setSearchQuery);
260
+ const toggleSourceFilter = useStore((s) => s.toggleSourceFilter);
261
+ const toggleGroupExpanded = useStore((s) => s.toggleGroupExpanded);
262
+ const setCurrentSession = useStore((s) => s.setCurrentSession);
263
+ const setSidebarWidth = useStore((s) => s.setSidebarWidth);
264
+ const setSettingsOpen = useStore((s) => s.setSettingsOpen);
265
+ const setSidebarTab = useStore((s) => s.setSidebarTab);
266
+
267
+ const searchRef = useRef<HTMLInputElement>(null);
268
+ const sidebarRef = useRef<HTMLDivElement>(null);
269
+ const [localSearch, setLocalSearch] = useState(searchQuery);
270
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
271
+ const ftSearchRef = useRef<ReturnType<typeof setTimeout>>(undefined);
272
+ const dragging = useRef(false);
273
+ const [ftResults, setFtResults] = useState<{ session: SessionInfo; snippet: string }[]>([]);
274
+ const [ftLoading, setFtLoading] = useState(false);
275
+ const [ftActive, setFtActive] = useState(false);
276
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; session: SessionInfo } | null>(null);
277
+
278
+ const handleSearchChange = useCallback(
279
+ (value: string) => {
280
+ setLocalSearch(value);
281
+ if (debounceRef.current) clearTimeout(debounceRef.current);
282
+ debounceRef.current = setTimeout(() => {
283
+ setSearchQuery(value);
284
+ }, 120);
285
+
286
+ // Full-text search for 2+ chars
287
+ if (ftSearchRef.current) clearTimeout(ftSearchRef.current);
288
+ if (value.length >= 2) {
289
+ ftSearchRef.current = setTimeout(() => {
290
+ setFtLoading(true);
291
+ setFtActive(true);
292
+ fetch("/api/search", {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: JSON.stringify({ query: value }),
296
+ })
297
+ .then((r) => r.json())
298
+ .then((data) => {
299
+ if (Array.isArray(data)) setFtResults(data);
300
+ setFtLoading(false);
301
+ })
302
+ .catch(() => {
303
+ setFtLoading(false);
304
+ });
305
+ }, 300);
306
+ } else {
307
+ setFtResults([]);
308
+ setFtActive(false);
309
+ }
310
+ },
311
+ [setSearchQuery]
312
+ );
313
+
314
+ // Build parent/child map — use full sessions list for detection
315
+ const childrenOf = new Map<string, SessionInfo[]>();
316
+ const childIds = new Set<string>();
317
+ const parentIds = new Set<string>();
318
+
319
+ // First pass: detect all parent-child relationships from full sessions list
320
+ for (const s of sessions) {
321
+ const isKovaSub = s.key?.startsWith("agent:main:subagent:");
322
+ let parentId = s.parentSessionId;
323
+ if (!parentId && isKovaSub) {
324
+ const main = sessions.find((p) => p.key === "agent:main:main");
325
+ if (main) parentId = main.sessionId;
326
+ }
327
+ if (parentId) {
328
+ parentIds.add(parentId);
329
+ const parentByKey = sessions.find((p) => p.key === parentId);
330
+ if (parentByKey) parentIds.add(parentByKey.sessionId);
331
+ }
332
+ }
333
+
334
+ // Second pass: build children map from filtered sessions only (for display)
335
+ for (const s of filteredSessions) {
336
+ const isKovaSub = s.key?.startsWith("agent:main:subagent:");
337
+ let parentId = s.parentSessionId;
338
+ if (!parentId && isKovaSub) {
339
+ const main = filteredSessions.find((p) => p.key === "agent:main:main");
340
+ if (main) parentId = main.sessionId;
341
+ }
342
+ if (parentId) {
343
+ if (!childrenOf.has(parentId)) childrenOf.set(parentId, []);
344
+ childrenOf.get(parentId)!.push(s);
345
+ childIds.add(s.sessionId);
346
+ }
347
+ }
348
+
349
+ // hasSubagents: use the direct flag from scanner OR derive from parentSessionId links
350
+ const hasSubagentsSet = new Set<string>();
351
+ for (const s of sessions) {
352
+ if (s.hasSubagents) {
353
+ hasSubagentsSet.add(s.sessionId);
354
+ }
355
+ const pid = s.parentSessionId;
356
+ if (pid) {
357
+ hasSubagentsSet.add(pid);
358
+ for (const p of sessions) {
359
+ if (p.key === pid || p.sessionId === pid) hasSubagentsSet.add(p.sessionId);
360
+ }
361
+ }
362
+ if (s.key?.startsWith("agent:main:subagent:")) {
363
+ const main = sessions.find((p) => p.key === "agent:main:main");
364
+ if (main) hasSubagentsSet.add(main.sessionId);
365
+ }
366
+ }
367
+
368
+ // Determine which providers have sessions
369
+ const activeSources = new Set<string>();
370
+ for (const s of sessions) {
371
+ activeSources.add(s.source || "kova");
372
+ }
373
+
374
+ const handleSelect = useCallback(
375
+ (id: string) => {
376
+ setCurrentSession(id);
377
+ },
378
+ [setCurrentSession]
379
+ );
380
+
381
+ const handleContextMenu = useCallback(
382
+ (e: React.MouseEvent, session: SessionInfo) => {
383
+ e.preventDefault();
384
+ setCtxMenu({ x: e.clientX, y: e.clientY, session });
385
+ },
386
+ []
387
+ );
388
+
389
+ // Keyboard shortcut
390
+ useEffect(() => {
391
+ const handleKey = (e: KeyboardEvent) => {
392
+ const mod = e.metaKey || e.ctrlKey;
393
+ if (mod && e.key === "k") {
394
+ e.preventDefault();
395
+ searchRef.current?.focus();
396
+ searchRef.current?.select();
397
+ }
398
+ };
399
+ document.addEventListener("keydown", handleKey);
400
+ return () => document.removeEventListener("keydown", handleKey);
401
+ }, []);
402
+
403
+ // Drag-to-resize sidebar
404
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
405
+ e.preventDefault();
406
+ dragging.current = true;
407
+ document.body.style.cursor = "col-resize";
408
+ document.body.style.userSelect = "none";
409
+
410
+ const startX = e.clientX;
411
+ const startW = sidebarRef.current?.offsetWidth || 280;
412
+
413
+ const onMove = (ev: MouseEvent) => {
414
+ if (!dragging.current) return;
415
+ const newW = startW + (ev.clientX - startX);
416
+ setSidebarWidth(newW);
417
+ };
418
+
419
+ const onUp = () => {
420
+ dragging.current = false;
421
+ document.body.style.cursor = "";
422
+ document.body.style.userSelect = "";
423
+ document.removeEventListener("mousemove", onMove);
424
+ document.removeEventListener("mouseup", onUp);
425
+ };
426
+
427
+ document.addEventListener("mousemove", onMove);
428
+ document.addEventListener("mouseup", onUp);
429
+ }, [setSidebarWidth]);
430
+
431
+ // Only show filter badges for providers that have sessions
432
+ const allBadges = [
433
+ { key: "kova", label: "kova" },
434
+ { key: "claude", label: "claude" },
435
+ { key: "codex", label: "codex" },
436
+ { key: "kimi", label: "kimi" },
437
+ { key: "gemini", label: "gemini" },
438
+ { key: "copilot", label: "copilot" },
439
+ { key: "factory", label: "factory" },
440
+ { key: "opencode", label: "opencode" },
441
+ { key: "aider", label: "aider" },
442
+ { key: "continue", label: "continue" },
443
+ { key: "cursor", label: "cursor" },
444
+ ];
445
+ const srcBadges = allBadges.filter(b => activeSources.has(b.key));
446
+
447
+ return (
448
+ <div
449
+ className="sidebar"
450
+ ref={sidebarRef}
451
+ style={{ width: sidebarWidth, minWidth: sidebarWidth }}
452
+ >
453
+ <div className="sidebar-header">
454
+ <div className="sidebar-title-row">
455
+ <Logo />
456
+ <span className="sidebar-count">{sessions.length}</span>
457
+ </div>
458
+
459
+ <div className="search-wrap">
460
+ <span className="search-icon"><SearchIcon /></span>
461
+ <input
462
+ ref={searchRef}
463
+ type="text"
464
+ placeholder="Search sessions (2+ chars: full-text)"
465
+ autoComplete="off"
466
+ spellCheck={false}
467
+ value={localSearch}
468
+ onChange={(e) => handleSearchChange(e.target.value)}
469
+ onKeyDown={(e) => {
470
+ if (e.key === "Escape") {
471
+ setLocalSearch("");
472
+ setSearchQuery("");
473
+ setFtResults([]);
474
+ setFtActive(false);
475
+ if (debounceRef.current) clearTimeout(debounceRef.current);
476
+ if (ftSearchRef.current) clearTimeout(ftSearchRef.current);
477
+ searchRef.current?.blur();
478
+ }
479
+ }}
480
+ className="search-input"
481
+ />
482
+ <span className="search-kbd">&#8984;K</span>
483
+ </div>
484
+
485
+ <div className="source-filters">
486
+ {srcBadges.map(({ key, label }) => (
487
+ <label key={key} className="source-filter">
488
+ <input
489
+ type="checkbox"
490
+ checked={sourceFilters[key] !== false}
491
+ onChange={() => toggleSourceFilter(key)}
492
+ />
493
+ <span
494
+ className={`source-filter-label ${sourceFilters[key] !== false ? "active" : "inactive"}`}
495
+ data-source={key}
496
+ >
497
+ {label}
498
+ </span>
499
+ </label>
500
+ ))}
501
+ </div>
502
+
503
+ {/* Browse / Starred / Pinned / Archived / Analytics tabs */}
504
+ <div className="sidebar-tabs">
505
+ <button
506
+ className={`sidebar-tab ${sidebarTab === "browse" ? "active" : ""}`}
507
+ onClick={() => setSidebarTab("browse")}
508
+ >browse</button>
509
+ <button
510
+ className={`sidebar-tab ${sidebarTab === "starred" ? "active" : ""}`}
511
+ onClick={() => setSidebarTab("starred")}
512
+ >
513
+ <svg width="9" height="9" viewBox="0 0 16 16" fill="none" style={{ marginRight: 3, verticalAlign: -1 }}>
514
+ <path d="M8 1l1.8 3.6 4 .6-2.9 2.8.7 4L8 10l-3.6 1.9.7-4L2.2 5.2l4-.6z"
515
+ fill={sidebarTab === "starred" ? "#F59E0B" : "none"}
516
+ stroke={sidebarTab === "starred" ? "#F59E0B" : "currentColor"}
517
+ strokeWidth="1.2" strokeLinejoin="round"/>
518
+ </svg>
519
+ starred
520
+ {starredSessionIds.size > 0 && (
521
+ <span className="sidebar-tab-count">{starredSessionIds.size}</span>
522
+ )}
523
+ </button>
524
+ <button
525
+ className={`sidebar-tab ${sidebarTab === "pinned" ? "active" : ""}`}
526
+ onClick={() => setSidebarTab("pinned")}
527
+ >
528
+ <svg width="9" height="9" viewBox="0 0 24 24" fill="none" style={{ marginRight: 3, verticalAlign: -1 }}>
529
+ <path d="M5 17H19V13L17 5H7L5 13V17Z"
530
+ fill={sidebarTab === "pinned" ? "#9B72EF" : "none"}
531
+ stroke={sidebarTab === "pinned" ? "#9B72EF" : "currentColor"}
532
+ strokeWidth="1.4" strokeLinejoin="round"/>
533
+ <line x1="5" y1="9" x2="19" y2="9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
534
+ <line x1="12" y1="17" x2="12" y2="22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
535
+ </svg>
536
+ pinned
537
+ {Object.values(pinnedMessages).filter(v => v.length > 0).length > 0 && (
538
+ <span className="sidebar-tab-count">{Object.values(pinnedMessages).filter(v => v.length > 0).length}</span>
539
+ )}
540
+ </button>
541
+ <button
542
+ className={`sidebar-tab ${sidebarTab === "archived" ? "active" : ""}`}
543
+ onClick={() => setSidebarTab("archived")}
544
+ >
545
+ archived
546
+ {archivedSessionIds.size > 0 && (
547
+ <span className="sidebar-tab-count">{archivedSessionIds.size}</span>
548
+ )}
549
+ </button>
550
+ <button
551
+ className={`sidebar-tab ${sidebarTab === "analytics" ? "active" : ""}`}
552
+ onClick={() => setSidebarTab("analytics")}
553
+ >analytics</button>
554
+ </div>
555
+ </div>
556
+
557
+ {settingsOpen ? (
558
+ <SettingsPanel />
559
+ ) : ftActive ? (
560
+ <div className="session-list scroller">
561
+ {ftLoading ? (
562
+ <div className="session-list-empty">
563
+ <div className="spinner" style={{ width: 12, height: 12 }} /> Searching&hellip;
564
+ </div>
565
+ ) : ftResults.length === 0 ? (
566
+ <div className="session-list-empty">No content matches</div>
567
+ ) : (
568
+ ftResults.map(({ session: s, snippet }) => {
569
+ const src = s.source || "kova";
570
+ return (
571
+ <div
572
+ key={s.sessionId}
573
+ className={`session-item ft-result ${currentSessionId === s.sessionId ? "selected" : ""}`}
574
+ onClick={() => handleSelect(s.sessionId)}
575
+ >
576
+ <div className="session-row">
577
+ <div className={`session-dot ${s.isActive ? "active" : "inactive"}`} />
578
+ <span className="session-label">{sessionLabel(s)}</span>
579
+ <span className="session-source" style={{ color: sourceColors[src] || "var(--text-2)" }}>{src}</span>
580
+ </div>
581
+ <div className="ft-snippet">{snippet}</div>
582
+ </div>
583
+ );
584
+ })
585
+ )}
586
+ </div>
587
+ ) : (
588
+ <div className="session-list scroller">
589
+ {filteredSessions.length === 0 ? (
590
+ <div className="session-list-empty">
591
+ {searchQuery ? "No matches" : sidebarTab === "archived" ? "No archived sessions" : sidebarTab === "starred" ? "No starred sessions" : sidebarTab === "pinned" ? "No sessions with pins" : "No sessions"}
592
+ </div>
593
+ ) : (
594
+ filteredSessions.map((s) => {
595
+ if (childIds.has(s.sessionId)) return null;
596
+ // Tab-specific filters
597
+ if (sidebarTab === "starred" && !starredSessionIds.has(s.sessionId)) return null;
598
+ if (sidebarTab === "pinned" && !(pinnedMessages[s.sessionId]?.length > 0)) return null;
599
+ const children = childrenOf.get(s.sessionId) || [];
600
+ const isExpanded = expandedGroups.has(s.sessionId);
601
+ const isLive = activeSessions.has(s.sessionId);
602
+ const isArchived = archivedSessionIds.has(s.sessionId);
603
+
604
+ return (
605
+ <div key={s.sessionId}>
606
+ <SessionItem
607
+ session={s}
608
+ isSubagent={false}
609
+ childCount={children.length}
610
+ hasSubagents={hasSubagentsSet.has(s.sessionId)}
611
+ isExpanded={isExpanded}
612
+ isSelected={currentSessionId === s.sessionId}
613
+ isLive={isLive}
614
+ isArchived={isArchived}
615
+ isStarred={starredSessionIds.has(s.sessionId)}
616
+ compact={compactSidebar}
617
+ onSelect={handleSelect}
618
+ onToggleExpand={toggleGroupExpanded}
619
+ onContextMenu={handleContextMenu}
620
+ onToggleStar={toggleStarred}
621
+ />
622
+ {children.length > 0 && isExpanded && (
623
+ <div className="subagent-children">
624
+ {children.map((c) => (
625
+ <SessionItem
626
+ key={c.sessionId}
627
+ session={c}
628
+ isSubagent={true}
629
+ childCount={0}
630
+ hasSubagents={hasSubagentsSet.has(c.sessionId)}
631
+ isExpanded={false}
632
+ isSelected={currentSessionId === c.sessionId}
633
+ isLive={activeSessions.has(c.sessionId)}
634
+ isArchived={archivedSessionIds.has(c.sessionId)}
635
+ isStarred={starredSessionIds.has(c.sessionId)}
636
+ compact={compactSidebar}
637
+ onSelect={handleSelect}
638
+ onToggleExpand={toggleGroupExpanded}
639
+ onContextMenu={handleContextMenu}
640
+ onToggleStar={toggleStarred}
641
+ />
642
+ ))}
643
+ </div>
644
+ )}
645
+ </div>
646
+ );
647
+ })
648
+ )}
649
+ </div>
650
+ )}
651
+
652
+ {/* Context menu */}
653
+ {ctxMenu && (
654
+ <ContextMenu
655
+ x={ctxMenu.x}
656
+ y={ctxMenu.y}
657
+ session={ctxMenu.session}
658
+ isArchived={archivedSessionIds.has(ctxMenu.session.sessionId)}
659
+ onClose={() => setCtxMenu(null)}
660
+ />
661
+ )}
662
+
663
+ {/* Footer: expand/collapse all + settings */}
664
+ <div className="sidebar-footer">
665
+ <div className="sidebar-footer-group">
666
+ <button
667
+ className="sidebar-footer-btn"
668
+ onClick={expandAllGroups}
669
+ title="Expand all subagent groups"
670
+ >
671
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
672
+ <path d="M3 5l5 5 5-5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
673
+ <path d="M3 2h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
674
+ </svg>
675
+ <span>expand all</span>
676
+ </button>
677
+ <button
678
+ className="sidebar-footer-btn"
679
+ onClick={collapseAllGroups}
680
+ title="Collapse all subagent groups"
681
+ >
682
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
683
+ <path d="M3 11l5-5 5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
684
+ <path d="M3 14h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
685
+ </svg>
686
+ <span>collapse all</span>
687
+ </button>
688
+ </div>
689
+ <button
690
+ className={`sidebar-settings-btn ${settingsOpen ? "active" : ""}`}
691
+ onClick={() => setSettingsOpen(!settingsOpen)}
692
+ title="Settings"
693
+ >
694
+ <GearIcon />
695
+ <span>settings</span>
696
+ </button>
697
+ <button
698
+ className="sidebar-settings-btn"
699
+ onClick={() => { window.location.href = "/?setup=1"; }}
700
+ title="Configure agent paths"
701
+ >
702
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
703
+ <circle cx="8" cy="8" r="5.5" stroke="currentColor" strokeWidth="1.3"/>
704
+ <path d="M8 5v3l2 2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
705
+ </svg>
706
+ <span>agents</span>
707
+ </button>
708
+ </div>
709
+
710
+ {/* Drag handle */}
711
+ <div className="sidebar-resize-handle" onMouseDown={handleMouseDown} />
712
+ </div>
713
+ );
714
+ }