@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
package/web/src/App.tsx
ADDED
|
@@ -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
|
+
}
|