@hybridlabor-api/bdb-synapse 1.0.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,876 @@
1
+ import { PanelLeftOpen } from "lucide-react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ describeError,
5
+ getAgentTrace,
6
+ getRepoMap,
7
+ getSessionAgents,
8
+ getSessionReport,
9
+ getSessionSnapshot,
10
+ listSessions,
11
+ startSessionAnalyze
12
+ } from "./api/client";
13
+ import { Crosshair, Sparkles, Mountain, TreePine, Users } from "lucide-react";
14
+ import type { AgentGraph, CityMap, JudgeChoice, ReportStatus, Trace } from "./types";
15
+ import { Dock, type PanelDescriptor } from "./ui/Dock";
16
+ import { AgentsPanel } from "./ui/AgentsPanel";
17
+ import { ReportPanel } from "./ui/ReportPanel";
18
+ import { ViewPanel } from "./ui/ViewPanel";
19
+ import { PlaybackEngine } from "./playback/reducer";
20
+ import { downloadBlob, recordingSupported, recordPlayback } from "./playback/recorder";
21
+ import { CityScene } from "./scene/CityScene";
22
+ import { TreeScene } from "./scene/TreeScene";
23
+ import { sessionVisible } from "./state/filters";
24
+ import { useAppStore } from "./state/store";
25
+ import { Hud } from "./ui/Hud";
26
+ import { Inspector } from "./ui/Inspector";
27
+ import { SessionRail } from "./ui/SessionRail";
28
+ import { toggleRailShortcut } from "./ui/shortcuts";
29
+ import { Timeline } from "./ui/Timeline";
30
+ import "./styles.css";
31
+
32
+ function evaluateHint(badge: "running" | "done" | "stale" | "failed" | null): string {
33
+ switch (badge) {
34
+ case "running":
35
+ return "The judge is reading the trace — about a minute";
36
+ case "done":
37
+ return "Evaluation ready";
38
+ case "stale":
39
+ return "Evaluation ready, but the session has grown since";
40
+ case "failed":
41
+ return "The last evaluation failed — open to retry";
42
+ default:
43
+ return "Evaluate this session with your local agent CLI";
44
+ }
45
+ }
46
+
47
+ const MAIN_ACTOR_KEY = "__main__";
48
+
49
+ export default function App() {
50
+ const {
51
+ sessions,
52
+ activeSessionKey,
53
+ trace,
54
+ city,
55
+ currentSeq,
56
+ selectedPath,
57
+ view,
58
+ loading,
59
+ error,
60
+ hideEmpty,
61
+ harnessFilter,
62
+ railCollapsed,
63
+ mapOnly,
64
+ setView,
65
+ setSessions,
66
+ setActiveSession,
67
+ setData,
68
+ setCityOnly,
69
+ setCurrentSeq,
70
+ setSelectedPath,
71
+ setLoading,
72
+ setError,
73
+ setHideEmpty,
74
+ setHarnessFilter,
75
+ setRailCollapsed
76
+ } = useAppStore();
77
+ const urlSessionConsumed = useRef(false);
78
+ const scanGeneration = useRef(0);
79
+ const loadGeneration = useRef(0);
80
+ const lensGeneration = useRef(0);
81
+ const agentGraphRequest = useRef(0);
82
+ const agentTraceRequest = useRef(0);
83
+ const manualRefreshInFlight = useRef(false);
84
+ const pendingLoads = useRef(0);
85
+ const activeSessionKeyRef = useRef(activeSessionKey);
86
+ activeSessionKeyRef.current = activeSessionKey;
87
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
88
+ const [exporting, setExporting] = useState(false);
89
+ const [openSheet, setOpenSheet] = useState<string | null>(null);
90
+ const [openPop, setOpenPop] = useState<string | null>(null);
91
+ const [reportStatus, setReportStatus] = useState<ReportStatus | undefined>();
92
+ const [agentGraph, setAgentGraph] = useState<AgentGraph | undefined>();
93
+ const [activeAgentID, setActiveAgentID] = useState<string | null>(null);
94
+ const [agentGraphLoading, setAgentGraphLoading] = useState(false);
95
+ const [loadingAgentID, setLoadingAgentID] = useState<string | undefined>();
96
+ const [agentPanelError, setAgentPanelError] = useState<string | undefined>();
97
+ const [agentRetryID, setAgentRetryID] = useState<string | null | undefined>();
98
+ const activeAgentIDRef = useRef<string | null>(null);
99
+ const pendingAgentIDRef = useRef<string | undefined>(undefined);
100
+ const rootTraceRef = useRef<Trace | undefined>(undefined);
101
+ const rootCityRef = useRef<CityMap | undefined>(undefined);
102
+ const actorTraceCache = useRef(new Map<string, Trace>());
103
+ const actorPlayheads = useRef(new Map<string, number>());
104
+ const exportingRef = useRef(false);
105
+ exportingRef.current = exporting;
106
+
107
+ // scenes hand up their live <canvas> so the video exporter can capture it;
108
+ // stable identity keeps the scene mount effect from remounting on every render
109
+ const handleCanvasReady = useCallback((canvas: HTMLCanvasElement | null) => {
110
+ canvasRef.current = canvas;
111
+ }, []);
112
+
113
+ const beginLoading = useCallback(() => {
114
+ pendingLoads.current++;
115
+ setLoading(true);
116
+ }, [setLoading]);
117
+
118
+ const endLoading = useCallback(() => {
119
+ pendingLoads.current = Math.max(0, pendingLoads.current - 1);
120
+ if (pendingLoads.current === 0) setLoading(false);
121
+ }, [setLoading]);
122
+
123
+ const resetLens = useCallback(() => {
124
+ const generation = ++lensGeneration.current;
125
+ agentGraphRequest.current++;
126
+ agentTraceRequest.current++;
127
+ activeAgentIDRef.current = null;
128
+ pendingAgentIDRef.current = undefined;
129
+ rootTraceRef.current = undefined;
130
+ rootCityRef.current = undefined;
131
+ actorTraceCache.current.clear();
132
+ actorPlayheads.current.clear();
133
+ setAgentGraph(undefined);
134
+ setActiveAgentID(null);
135
+ setAgentGraphLoading(false);
136
+ setLoadingAgentID(undefined);
137
+ setAgentPanelError(undefined);
138
+ setAgentRetryID(undefined);
139
+ return generation;
140
+ }, []);
141
+
142
+ const loadAgentGraph = useCallback(async (key: string, generation = lensGeneration.current) => {
143
+ const request = ++agentGraphRequest.current;
144
+ setAgentGraphLoading(true);
145
+ setAgentPanelError(undefined);
146
+ setAgentRetryID(undefined);
147
+ try {
148
+ const graph = await getSessionAgents(key);
149
+ if (
150
+ generation !== lensGeneration.current ||
151
+ request !== agentGraphRequest.current ||
152
+ activeSessionKeyRef.current !== key
153
+ ) {
154
+ return;
155
+ }
156
+ setAgentGraph(graph);
157
+ } catch (err) {
158
+ if (
159
+ generation === lensGeneration.current &&
160
+ request === agentGraphRequest.current &&
161
+ activeSessionKeyRef.current === key
162
+ ) {
163
+ setAgentPanelError(describeError(err, "loading agents"));
164
+ setAgentRetryID(null);
165
+ }
166
+ } finally {
167
+ if (
168
+ generation === lensGeneration.current &&
169
+ request === agentGraphRequest.current &&
170
+ activeSessionKeyRef.current === key
171
+ ) {
172
+ setAgentGraphLoading(false);
173
+ }
174
+ }
175
+ }, []);
176
+
177
+ const loadSession = useCallback(async (key: string) => {
178
+ const generation = ++loadGeneration.current;
179
+ const currentLensGeneration = lensGeneration.current;
180
+ beginLoading();
181
+ setError(undefined);
182
+ try {
183
+ const { trace: nextTrace, city: nextCity } = await getSessionSnapshot(key);
184
+ if (
185
+ generation !== loadGeneration.current ||
186
+ currentLensGeneration !== lensGeneration.current ||
187
+ activeSessionKeyRef.current !== key
188
+ ) {
189
+ return;
190
+ }
191
+ rootTraceRef.current = nextTrace;
192
+ rootCityRef.current = nextCity;
193
+ actorTraceCache.current.set(MAIN_ACTOR_KEY, nextTrace);
194
+ const activeChildID = activeAgentIDRef.current;
195
+ if (activeChildID === null) {
196
+ setData(nextTrace, nextCity);
197
+ const remembered = actorPlayheads.current.get(MAIN_ACTOR_KEY);
198
+ if (remembered !== undefined) {
199
+ setCurrentSeq(Math.min(remembered, Math.max(0, nextTrace.events.length - 1)));
200
+ }
201
+ setSelectedPath(undefined);
202
+ } else {
203
+ const childTrace = actorTraceCache.current.get(activeChildID);
204
+ if (childTrace) {
205
+ const seq = useAppStore.getState().currentSeq;
206
+ setData(childTrace, nextCity);
207
+ setCurrentSeq(Math.min(seq, Math.max(0, childTrace.events.length - 1)));
208
+ }
209
+ }
210
+ } catch (err) {
211
+ if (generation === loadGeneration.current && activeSessionKeyRef.current === key) {
212
+ setError(describeError(err, "loading the session"));
213
+ }
214
+ } finally {
215
+ endLoading();
216
+ }
217
+ }, [beginLoading, endLoading, setCurrentSeq, setData, setError, setSelectedPath]);
218
+
219
+ const invalidateActorTracesForRescan = useCallback(() => {
220
+ const activeActorID = activeAgentIDRef.current;
221
+ actorPlayheads.current.set(
222
+ activeActorID ?? MAIN_ACTOR_KEY,
223
+ useAppStore.getState().currentSeq
224
+ );
225
+ agentTraceRequest.current++;
226
+ pendingAgentIDRef.current = undefined;
227
+ actorTraceCache.current.clear();
228
+ activeAgentIDRef.current = null;
229
+ setActiveAgentID(null);
230
+ setLoadingAgentID(undefined);
231
+ setAgentPanelError(undefined);
232
+ setAgentRetryID(undefined);
233
+
234
+ const rootTrace = rootTraceRef.current;
235
+ const rootCity = rootCityRef.current;
236
+ if (rootTrace && rootCity) {
237
+ setData(rootTrace, rootCity);
238
+ const remembered = actorPlayheads.current.get(MAIN_ACTOR_KEY);
239
+ if (remembered !== undefined) {
240
+ setCurrentSeq(Math.min(remembered, Math.max(0, rootTrace.events.length - 1)));
241
+ }
242
+ setSelectedPath(undefined);
243
+ }
244
+ }, [setCurrentSeq, setData, setSelectedPath]);
245
+
246
+ const scan = useCallback(async (fresh: boolean) => {
247
+ const generation = ++scanGeneration.current;
248
+ beginLoading();
249
+ setError(undefined);
250
+ try {
251
+ const data = await listSessions(fresh);
252
+ if (generation !== scanGeneration.current) return;
253
+ setSessions(data);
254
+ let preferred: string | undefined;
255
+ if (!urlSessionConsumed.current) {
256
+ urlSessionConsumed.current = true;
257
+ const selector = new URL(window.location.href).searchParams.get("session") ?? undefined;
258
+ const exact = selector ? data.find((session) => session.key === selector) : undefined;
259
+ const legacyMatches = selector && !exact ? data.filter((session) => session.id === selector) : [];
260
+ const fromUrl = exact?.key ?? (legacyMatches.length === 1 ? legacyMatches[0].key : undefined);
261
+ if (fromUrl) {
262
+ preferred = fromUrl;
263
+ } else if (legacyMatches.length > 1) {
264
+ console.warn(`session id "${selector}" is ambiguous; falling back to the latest session`);
265
+ } else if (selector) {
266
+ console.warn(`session "${selector}" not found; falling back to the latest session`);
267
+ }
268
+ }
269
+ // a session can disappear between scans; fall back instead of pinning a dead key
270
+ const currentActiveKey = activeSessionKeyRef.current;
271
+ const stillListed =
272
+ currentActiveKey !== undefined && data.some((session) => session.key === currentActiveKey);
273
+ // prefer a session the rail will actually show; if the filters hide
274
+ // everything, the newest session still beats a blank stage
275
+ const fallback = (
276
+ data.find((session) => sessionVisible(session, { hideEmpty, harness: harnessFilter })) ?? data[0]
277
+ )?.key;
278
+ const next = preferred ?? (stillListed ? currentActiveKey : fallback);
279
+ if (next !== currentActiveKey) {
280
+ const lens = resetLens();
281
+ activeSessionKeyRef.current = next;
282
+ if (!next) loadGeneration.current++;
283
+ setActiveSession(next);
284
+ if (next) void loadAgentGraph(next, lens);
285
+ } else if (fresh && next) {
286
+ invalidateActorTracesForRescan();
287
+ void loadAgentGraph(next);
288
+ }
289
+ if (next) await loadSession(next);
290
+ } catch (err) {
291
+ if (generation === scanGeneration.current) {
292
+ setError(describeError(err, "scanning sessions"));
293
+ }
294
+ } finally {
295
+ endLoading();
296
+ }
297
+ }, [beginLoading, endLoading, harnessFilter, hideEmpty, invalidateActorTracesForRescan, loadAgentGraph, loadSession, resetLens, setActiveSession, setError, setSessions]);
298
+
299
+ const loadRepoMap = useCallback(async (repo?: string) => {
300
+ beginLoading();
301
+ setError(undefined);
302
+ try {
303
+ const city = await getRepoMap(repo);
304
+ setCityOnly(city);
305
+ } catch (err) {
306
+ setError(describeError(err, "loading the repository map"));
307
+ } finally {
308
+ endLoading();
309
+ }
310
+ }, [beginLoading, endLoading, setCityOnly, setError]);
311
+
312
+ // open the static map for a repo in a new tab so the running session stays put
313
+ const openMap = useCallback((repo?: string) => {
314
+ const url = repo ? `/?map=1&repo=${encodeURIComponent(repo)}` : "/?map=1";
315
+ window.open(url, "_blank", "noopener");
316
+ }, []);
317
+
318
+ const selectSession = useCallback((key: string) => {
319
+ if (activeSessionKeyRef.current === key) return;
320
+ const lens = resetLens();
321
+ activeSessionKeyRef.current = key;
322
+ setActiveSession(key);
323
+ void loadAgentGraph(key, lens);
324
+ void loadSession(key);
325
+ }, [loadAgentGraph, loadSession, resetLens, setActiveSession]);
326
+
327
+ const saveActivePlayhead = useCallback(() => {
328
+ const key = activeAgentIDRef.current ?? MAIN_ACTOR_KEY;
329
+ actorPlayheads.current.set(key, useAppStore.getState().currentSeq);
330
+ }, []);
331
+
332
+ const showCachedActor = useCallback((agentID: string | null, nextTrace: Trace, nextCity: CityMap) => {
333
+ saveActivePlayhead();
334
+ activeAgentIDRef.current = agentID;
335
+ setActiveAgentID(agentID);
336
+ setData(nextTrace, nextCity);
337
+ const remembered = actorPlayheads.current.get(agentID ?? MAIN_ACTOR_KEY);
338
+ if (remembered !== undefined) {
339
+ setCurrentSeq(Math.min(remembered, Math.max(0, nextTrace.events.length - 1)));
340
+ }
341
+ setSelectedPath(undefined);
342
+ }, [saveActivePlayhead, setCurrentSeq, setData, setSelectedPath]);
343
+
344
+ const selectAgent = useCallback(async (agentID: string | null) => {
345
+ if (exportingRef.current) return;
346
+ const rootKey = activeSessionKeyRef.current;
347
+ const nextCity = rootCityRef.current;
348
+ if (!rootKey || !nextCity) return;
349
+
350
+ const request = ++agentTraceRequest.current;
351
+ pendingAgentIDRef.current = undefined;
352
+ setLoadingAgentID(undefined);
353
+ setAgentPanelError(undefined);
354
+ setAgentRetryID(undefined);
355
+
356
+ if (activeAgentIDRef.current === agentID) return;
357
+
358
+ const cachedTrace =
359
+ agentID === null ? rootTraceRef.current : actorTraceCache.current.get(agentID);
360
+ if (cachedTrace) {
361
+ showCachedActor(agentID, cachedTrace, nextCity);
362
+ return;
363
+ }
364
+ if (agentID === null) return;
365
+
366
+ const node = agentGraph?.agents.find((agent) => agent.id === agentID);
367
+ if (!node || node.traceAvailability !== "available") return;
368
+
369
+ const generation = lensGeneration.current;
370
+ pendingAgentIDRef.current = agentID;
371
+ setLoadingAgentID(agentID);
372
+ try {
373
+ const nextTrace = await getAgentTrace(rootKey, agentID);
374
+ if (
375
+ generation !== lensGeneration.current ||
376
+ request !== agentTraceRequest.current ||
377
+ pendingAgentIDRef.current !== agentID ||
378
+ activeSessionKeyRef.current !== rootKey ||
379
+ exportingRef.current
380
+ ) {
381
+ return;
382
+ }
383
+ actorTraceCache.current.set(agentID, nextTrace);
384
+ showCachedActor(agentID, nextTrace, rootCityRef.current ?? nextCity);
385
+ } catch (err) {
386
+ if (
387
+ generation === lensGeneration.current &&
388
+ request === agentTraceRequest.current &&
389
+ pendingAgentIDRef.current === agentID &&
390
+ activeSessionKeyRef.current === rootKey
391
+ ) {
392
+ setAgentPanelError(describeError(err, `loading the ${node.label} trace`));
393
+ setAgentRetryID(agentID);
394
+ }
395
+ } finally {
396
+ if (
397
+ generation === lensGeneration.current &&
398
+ request === agentTraceRequest.current &&
399
+ pendingAgentIDRef.current === agentID &&
400
+ activeSessionKeyRef.current === rootKey
401
+ ) {
402
+ pendingAgentIDRef.current = undefined;
403
+ setLoadingAgentID(undefined);
404
+ }
405
+ }
406
+ }, [agentGraph, showCachedActor]);
407
+
408
+ const retryAgents = useCallback(() => {
409
+ const key = activeSessionKeyRef.current;
410
+ if (exportingRef.current || !key || agentRetryID === undefined) return;
411
+ if (agentRetryID === null) void loadAgentGraph(key);
412
+ else void selectAgent(agentRetryID);
413
+ }, [agentRetryID, loadAgentGraph, selectAgent]);
414
+
415
+ const exportVideo = useCallback(async () => {
416
+ const canvas = canvasRef.current;
417
+ const total = trace?.events.length ?? 0;
418
+ if (!canvas || total === 0 || exportingRef.current) return;
419
+ // the recorder owns the playhead for the duration of the export; setting
420
+ // exporting=true locks the transport, scrubber, session rail, and view
421
+ // toggle (see the `exporting` prop threaded into Timeline/SessionRail/Hud)
422
+ // so nothing else moves the playhead or swaps the canvas mid-recording
423
+ const exportSessionKey = activeSessionKeyRef.current;
424
+ const exportActorID = activeAgentIDRef.current;
425
+ const resumeSeq = useAppStore.getState().currentSeq;
426
+ agentTraceRequest.current++;
427
+ pendingAgentIDRef.current = undefined;
428
+ setLoadingAgentID(undefined);
429
+ exportingRef.current = true;
430
+ setExporting(true);
431
+ setError(undefined);
432
+ try {
433
+ const { blob, extension } = await recordPlayback({
434
+ canvas,
435
+ total,
436
+ setSeq: setCurrentSeq
437
+ });
438
+ const name = trace?.session.id || exportSessionKey || "session";
439
+ downloadBlob(blob, `mindwalk-${name}.${extension}`);
440
+ } catch (err) {
441
+ setError(describeError(err, "exporting the video"));
442
+ } finally {
443
+ // only restore the playhead if we're still on the same session and actor —
444
+ // a guard in case a switch slipped through; normally the UI lock prevents it
445
+ if (
446
+ activeSessionKeyRef.current === exportSessionKey &&
447
+ activeAgentIDRef.current === exportActorID
448
+ ) {
449
+ setCurrentSeq(resumeSeq);
450
+ }
451
+ exportingRef.current = false;
452
+ setExporting(false);
453
+ }
454
+ }, [trace, exporting, setCurrentSeq, setError]);
455
+
456
+ // stable callbacks keep SessionRail's memo effective across playback ticks
457
+ const collapseRail = useCallback(() => setRailCollapsed(true), [setRailCollapsed]);
458
+ const expandRail = useCallback(() => setRailCollapsed(false), [setRailCollapsed]);
459
+
460
+ // --- session evaluation: fetched on session switch, polled while the judge
461
+ // runs; the judge itself only ever starts from the explicit button press
462
+ const refreshReport = useCallback(async (key: string) => {
463
+ try {
464
+ const status = await getSessionReport(key);
465
+ if (activeSessionKeyRef.current === key) setReportStatus(status);
466
+ } catch {
467
+ // not worth a toast: the status stays undefined, the panel keeps its
468
+ // "checking" state, and the unknown-status retry effect below tries
469
+ // again until the server answers
470
+ }
471
+ }, []);
472
+
473
+ useEffect(() => {
474
+ setOpenSheet(null);
475
+ setOpenPop(null);
476
+ setReportStatus(undefined);
477
+ if (activeSessionKey && !mapOnly) void refreshReport(activeSessionKey);
478
+ }, [activeSessionKey, mapOnly, refreshReport]);
479
+
480
+ // refreshes the rail's evaluation badges without disturbing selection —
481
+ // scan() owns selection fallback, this only swaps the list data
482
+ const refreshSessionList = useCallback(async () => {
483
+ try {
484
+ setSessions(await listSessions());
485
+ } catch {
486
+ // badge refresh is best-effort; the next scan will catch up
487
+ }
488
+ }, [setSessions]);
489
+
490
+ // manual rescan: the active key usually survives it, so the report status
491
+ // must be refetched explicitly — it may have gone stale or finished while
492
+ // we weren't polling
493
+ const refresh = useCallback(() => {
494
+ if (exportingRef.current || manualRefreshInFlight.current) return;
495
+ manualRefreshInFlight.current = true;
496
+ const key = activeSessionKeyRef.current;
497
+ if (key && !mapOnly) void refreshReport(key);
498
+ void scan(true).finally(() => {
499
+ manualRefreshInFlight.current = false;
500
+ });
501
+ }, [scan, refreshReport, mapOnly]);
502
+
503
+ useEffect(() => {
504
+ if (reportStatus?.state !== "running" || !activeSessionKey) return;
505
+ const timer = setInterval(() => {
506
+ void refreshReport(activeSessionKey);
507
+ void refreshSessionList();
508
+ }, 2500);
509
+ return () => {
510
+ clearInterval(timer);
511
+ // one more list pass so the rail badge leaves "evaluating" promptly
512
+ void refreshSessionList();
513
+ };
514
+ }, [reportStatus?.state, activeSessionKey, refreshReport, refreshSessionList]);
515
+
516
+ // while the report status is unknown (first request failed or still on its
517
+ // way), keep asking — otherwise a single dropped request would pin the
518
+ // panel to "checking" until a session switch or reload
519
+ useEffect(() => {
520
+ if (reportStatus !== undefined || !activeSessionKey || mapOnly) return;
521
+ const timer = setInterval(() => void refreshReport(activeSessionKey), 5000);
522
+ return () => clearInterval(timer);
523
+ }, [reportStatus === undefined, activeSessionKey, mapOnly, refreshReport]);
524
+
525
+ // a judge can be running for a session other than the active one; keep the
526
+ // rail badges honest by polling the list until every run finishes (the
527
+ // running-state effect above already polls while the active session runs)
528
+ const anyEvaluating = useMemo(() => sessions.some((s) => s.reportState === "running"), [sessions]);
529
+ useEffect(() => {
530
+ if (!anyEvaluating || reportStatus?.state === "running") return;
531
+ const timer = setInterval(() => void refreshSessionList(), 5000);
532
+ return () => clearInterval(timer);
533
+ }, [anyEvaluating, reportStatus?.state, refreshSessionList]);
534
+
535
+ const analyzeSession = useCallback(async (choice: JudgeChoice) => {
536
+ const key = activeSessionKeyRef.current;
537
+ if (!key) return;
538
+ try {
539
+ const status = await startSessionAnalyze(key, choice);
540
+ if (activeSessionKeyRef.current === key) setReportStatus(status);
541
+ void refreshSessionList();
542
+ } catch (err) {
543
+ setError(describeError(err, "starting the evaluation"));
544
+ }
545
+ }, [setError, refreshSessionList]);
546
+
547
+ // selecting a file in the scene opens the inspect sheet; deselecting keeps
548
+ // whatever panel the user had open
549
+ const selectFile = useCallback(
550
+ (path: string | undefined) => {
551
+ setSelectedPath(path);
552
+ if (path) setOpenSheet("inspect");
553
+ },
554
+ [setSelectedPath]
555
+ );
556
+
557
+ const closeSheet = useCallback(() => setOpenSheet(null), []);
558
+ const closePop = useCallback(() => setOpenPop(null), []);
559
+ const openAgents = useCallback(() => {
560
+ if (!exportingRef.current) setOpenSheet("agents");
561
+ }, []);
562
+
563
+ // sheets are exclusive (one full-height paper at a time); pops toggle
564
+ // independently so a view tweak never steals the open report
565
+ const togglePanel = useCallback((panel: PanelDescriptor) => {
566
+ if (panel.presentation === "sheet") {
567
+ setOpenSheet((current) => (current === panel.id ? null : panel.id));
568
+ } else {
569
+ setOpenPop((current) => (current === panel.id ? null : panel.id));
570
+ }
571
+ }, []);
572
+
573
+ // a finding jump moves the playhead and focuses the evidence's file so the
574
+ // claim is visible in the scene, not just in the panel — without stealing
575
+ // the dock away from the evaluation tab
576
+ const jumpToEvidence = useCallback(
577
+ (seq: number) => {
578
+ if (exportingRef.current) return;
579
+ const rootTrace = rootTraceRef.current;
580
+ const rootCity = rootCityRef.current;
581
+ if (!rootTrace || !rootCity) return;
582
+ agentTraceRequest.current++;
583
+ pendingAgentIDRef.current = undefined;
584
+ setLoadingAgentID(undefined);
585
+ if (activeAgentIDRef.current !== null) {
586
+ saveActivePlayhead();
587
+ activeAgentIDRef.current = null;
588
+ setActiveAgentID(null);
589
+ setData(rootTrace, rootCity);
590
+ }
591
+ setCurrentSeq(seq);
592
+ actorPlayheads.current.set(MAIN_ACTOR_KEY, seq);
593
+ const event = rootTrace.events[seq];
594
+ const path = event?.targets.find((target) => target.path)?.path;
595
+ setSelectedPath(path);
596
+ },
597
+ [saveActivePlayhead, setCurrentSeq, setData, setSelectedPath]
598
+ );
599
+
600
+ const openAgentsAtMark = useCallback((seq: number) => {
601
+ setCurrentSeq(seq);
602
+ setOpenSheet("agents");
603
+ }, [setCurrentSeq]);
604
+
605
+ const jumpToHistory = useCallback((seq: number) => {
606
+ if (exportingRef.current) return;
607
+ setCurrentSeq(seq);
608
+ }, [setCurrentSeq]);
609
+
610
+ useEffect(() => {
611
+ const onKeyDown = (e: KeyboardEvent) => {
612
+ if (e.key.toLowerCase() !== "b" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
613
+ e.preventDefault();
614
+ const store = useAppStore.getState();
615
+ store.setRailCollapsed(!store.railCollapsed);
616
+ };
617
+ window.addEventListener("keydown", onKeyDown);
618
+ return () => window.removeEventListener("keydown", onKeyDown);
619
+ }, []);
620
+
621
+ // V cycles the scene view without opening the view pop; locked during
622
+ // export because switching scenes would swap the recorded canvas
623
+ useEffect(() => {
624
+ const onKeyDown = (e: KeyboardEvent) => {
625
+ if (e.key.toLowerCase() !== "v" || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
626
+ const target = e.target as HTMLElement | null;
627
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
628
+ if (exportingRef.current) return;
629
+ const store = useAppStore.getState();
630
+ store.setView(store.view === "tree" ? "terrain" : "tree");
631
+ };
632
+ window.addEventListener("keydown", onKeyDown);
633
+ return () => window.removeEventListener("keydown", onKeyDown);
634
+ }, []);
635
+
636
+ useEffect(() => {
637
+ const params = new URL(window.location.href).searchParams;
638
+ if (params.get("map") === "1") {
639
+ void loadRepoMap(params.get("repo") ?? undefined);
640
+ } else {
641
+ void scan(false);
642
+ }
643
+ // eslint-disable-next-line react-hooks/exhaustive-deps
644
+ }, []);
645
+
646
+ const reportBadge = useMemo(() => {
647
+ if (reportStatus?.state === "running") return "running" as const;
648
+ if (reportStatus?.state === "failed") return "failed" as const;
649
+ if (reportStatus?.state === "done") return reportStatus.stale ? ("stale" as const) : ("done" as const);
650
+ return null;
651
+ }, [reportStatus]);
652
+
653
+ const agentLabel = useMemo(() => {
654
+ if (activeAgentID === null) return "Main";
655
+ return agentGraph?.agents.find((agent) => agent.id === activeAgentID)?.label ?? "Subagent";
656
+ }, [activeAgentID, agentGraph]);
657
+
658
+ const viewNote =
659
+ view === "tree"
660
+ ? trace
661
+ ? "glow ∝ revisits"
662
+ : "static map"
663
+ : trace
664
+ ? "height ∝ depth × revisits"
665
+ : "height ∝ lines";
666
+
667
+ const engine = useMemo(() => new PlaybackEngine(trace, city), [trace, city]);
668
+ const playback = useMemo(() => engine.snapshotAt(currentSeq), [engine, currentSeq]);
669
+ // live tallies for the HUD spectrum; touchByPath mirrors the backend stats scope
670
+ const touchCounts = useMemo(() => {
671
+ let edited = 0;
672
+ let read = 0;
673
+ let seen = 0;
674
+ for (const touch of playback.touchByPath.values()) {
675
+ if (touch === "edit") edited++;
676
+ else if (touch === "read") read++;
677
+ else seen++;
678
+ }
679
+ return { edited, read, seen };
680
+ }, [playback]);
681
+ const selectedFile = useMemo(
682
+ () => (selectedPath ? city?.files.find((file) => file.path === selectedPath) : undefined),
683
+ [city, selectedPath]
684
+ );
685
+ // mirrors the backend churn definition (stats.churnFiles): per path, the
686
+ // number of events that carried an edit touch; churn means three or more
687
+ const churn = useMemo(() => {
688
+ const counts = new Map<string, number>();
689
+ for (const event of trace?.events ?? []) {
690
+ for (const target of event.targets) {
691
+ if (target.touch === "edit" && target.path) {
692
+ counts.set(target.path, (counts.get(target.path) ?? 0) + 1);
693
+ }
694
+ }
695
+ }
696
+ return [...counts.entries()]
697
+ .filter(([, edits]) => edits >= 3)
698
+ .map(([path, edits]) => ({ path, edits }))
699
+ .sort((a, b) => b.edits - a.edits || (a.path < b.path ? -1 : 1));
700
+ }, [trace]);
701
+
702
+ return (
703
+ <main className={mapOnly ? "app-frame rail-collapsed" : railCollapsed ? "app-frame rail-collapsed" : "app-frame"}>
704
+ {mapOnly ? null : (
705
+ <SessionRail
706
+ sessions={sessions}
707
+ activeKey={activeSessionKey}
708
+ loading={loading}
709
+ hideEmpty={hideEmpty}
710
+ harnessFilter={harnessFilter}
711
+ collapsed={railCollapsed}
712
+ onSelect={selectSession}
713
+ onRefresh={refresh}
714
+ onHideEmptyChange={setHideEmpty}
715
+ onHarnessFilterChange={setHarnessFilter}
716
+ onCollapse={collapseRail}
717
+ onOpenMap={openMap}
718
+ activeRepo={trace?.session.cwd}
719
+ locked={exporting}
720
+ activeReportState={reportStatus === undefined ? undefined : reportBadge}
721
+ />
722
+ )}
723
+ <section className="stage">
724
+ <div className="viewport">
725
+ {!mapOnly && railCollapsed ? (
726
+ <button
727
+ className="rail-expand"
728
+ onClick={expandRail}
729
+ title={`Show sidebar (${toggleRailShortcut})`}
730
+ aria-label="Show session sidebar"
731
+ >
732
+ <PanelLeftOpen size={15} />
733
+ </button>
734
+ ) : null}
735
+ {view === "tree" ? (
736
+ <TreeScene
737
+ city={city}
738
+ playback={playback}
739
+ selectedPath={selectedPath}
740
+ onSelect={selectFile}
741
+ onCanvasReady={handleCanvasReady}
742
+ />
743
+ ) : (
744
+ <CityScene
745
+ city={city}
746
+ playback={playback}
747
+ selectedPath={selectedPath}
748
+ onSelect={selectFile}
749
+ onCanvasReady={handleCanvasReady}
750
+ locHeights={mapOnly}
751
+ />
752
+ )}
753
+ <Hud
754
+ trace={trace}
755
+ city={city}
756
+ agentLabel={agentLabel}
757
+ editedNow={touchCounts.edited}
758
+ readNow={touchCounts.read}
759
+ seenNow={touchCounts.seen}
760
+ churn={churn}
761
+ onSelectFile={selectFile}
762
+ onOpenAgents={!mapOnly && trace ? openAgents : undefined}
763
+ locked={exporting}
764
+ />
765
+ {city ? (
766
+ <Dock
767
+ panels={[
768
+ {
769
+ id: "view",
770
+ icon: view === "tree" ? TreePine : Mountain,
771
+ hint: `Scene view: ${view} — click to change, or press V`,
772
+ section: "scene",
773
+ presentation: "pop",
774
+ render: () => (
775
+ <ViewPanel view={view} onViewChange={setView} note={viewNote} locked={exporting} />
776
+ )
777
+ },
778
+ {
779
+ id: "inspect",
780
+ icon: Crosshair,
781
+ hint: "Inspect the selected file",
782
+ section: "session",
783
+ presentation: "sheet",
784
+ render: () => (
785
+ <Inspector
786
+ file={selectedFile}
787
+ touch={selectedFile ? playback.touchByPath.get(selectedFile.path) : undefined}
788
+ history={selectedFile ? (playback.historyByPath.get(selectedFile.path) ?? []) : []}
789
+ onClose={closeSheet}
790
+ onJumpTo={jumpToHistory}
791
+ locked={exporting}
792
+ />
793
+ )
794
+ },
795
+ ...(!mapOnly && trace
796
+ ? [
797
+ {
798
+ id: "agents",
799
+ icon: Users,
800
+ hint: `Agent lenses — current: ${agentLabel}`,
801
+ section: "session",
802
+ presentation: "sheet",
803
+ render: () => (
804
+ <AgentsPanel
805
+ graph={agentGraph}
806
+ current={activeAgentID}
807
+ loading={agentGraphLoading}
808
+ loadingAgentID={loadingAgentID}
809
+ locked={exporting}
810
+ error={agentPanelError}
811
+ retryAgentID={agentRetryID}
812
+ onSelect={(agentID) => void selectAgent(agentID)}
813
+ onRetry={retryAgents}
814
+ onClose={closeSheet}
815
+ />
816
+ )
817
+ } satisfies PanelDescriptor
818
+ ]
819
+ : []),
820
+ ...(!mapOnly && trace
821
+ ? [
822
+ {
823
+ id: "evaluate",
824
+ icon: Sparkles,
825
+ hint: evaluateHint(reportBadge),
826
+ section: "session",
827
+ presentation: "sheet",
828
+ badge: reportBadge,
829
+ render: () => (
830
+ <ReportPanel
831
+ status={reportStatus}
832
+ analyzing={reportStatus?.state === "running"}
833
+ locked={exporting}
834
+ onAnalyze={(choice) => void analyzeSession(choice)}
835
+ onClose={closeSheet}
836
+ onJumpTo={jumpToEvidence}
837
+ />
838
+ )
839
+ } satisfies PanelDescriptor
840
+ ]
841
+ : [])
842
+ ]}
843
+ openSheet={openSheet}
844
+ openPop={openPop}
845
+ onToggle={togglePanel}
846
+ onClosePop={closePop}
847
+ />
848
+ ) : null}
849
+ {!mapOnly && !loading && sessions.length === 0 ? (
850
+ <div className="empty-stage">
851
+ <div className="card">
852
+ <h2>No sessions found</h2>
853
+ <p>
854
+ Synapse scans <code>~/.claude/projects</code>, <code>~/.codex/sessions</code>,{" "}
855
+ <code>~/.pi/agent/sessions</code>, and <code>~/.gemini/antigravity-cli/brain</code> for agent traces. Run a session there, then refresh.
856
+ </p>
857
+ </div>
858
+ </div>
859
+ ) : null}
860
+ {loading ? (
861
+ <div className="toast">{mapOnly ? "Building the map…" : sessions.length === 0 ? "Scanning sessions…" : "Reading trace…"}</div>
862
+ ) : null}
863
+ {error ? <div className="toast error">{error}</div> : null}
864
+ </div>
865
+ <Timeline
866
+ trace={trace}
867
+ currentSeq={currentSeq}
868
+ onChange={setCurrentSeq}
869
+ onExport={recordingSupported() ? exportVideo : undefined}
870
+ exporting={exporting}
871
+ onSubagentMark={openAgentsAtMark}
872
+ />
873
+ </section>
874
+ </main>
875
+ );
876
+ }