@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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { Ellipsis, Loader, Pause, Play, RotateCcw, StepBack, StepForward, Video } from "lucide-react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { Action, Mark, Trace, TraceEvent } from "../types";
|
|
4
|
+
|
|
5
|
+
interface TimelineProps {
|
|
6
|
+
trace?: Trace;
|
|
7
|
+
currentSeq: number;
|
|
8
|
+
onChange: (seq: number) => void;
|
|
9
|
+
onSubagentMark?: (seq: number) => void;
|
|
10
|
+
onExport?: () => void;
|
|
11
|
+
exporting?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BUCKETS = 160;
|
|
15
|
+
const BASE_TICK_MS = 340;
|
|
16
|
+
const SPEEDS = [1, 4, 16] as const;
|
|
17
|
+
type Speed = (typeof SPEEDS)[number];
|
|
18
|
+
|
|
19
|
+
interface Bucket {
|
|
20
|
+
count: number;
|
|
21
|
+
dominant: Action;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// marks that land on the same strip pixel collapse into one glyph; the title
|
|
25
|
+
// carries the count so dense sessions stay legible
|
|
26
|
+
interface MarkGroup {
|
|
27
|
+
type: Mark["type"];
|
|
28
|
+
seq: number;
|
|
29
|
+
pos: number;
|
|
30
|
+
count: number;
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MARK_SLOTS = 220;
|
|
35
|
+
|
|
36
|
+
const MARK_LABEL: Record<Mark["type"], string> = {
|
|
37
|
+
compaction: "context compaction",
|
|
38
|
+
"user-message": "user message",
|
|
39
|
+
subagent: "subagent"
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const STRIP_ACTIONS: Action[] = ["search", "read", "edit", "verify", "exec"];
|
|
43
|
+
|
|
44
|
+
export function Timeline({
|
|
45
|
+
trace,
|
|
46
|
+
currentSeq,
|
|
47
|
+
onChange,
|
|
48
|
+
onSubagentMark,
|
|
49
|
+
onExport,
|
|
50
|
+
exporting = false
|
|
51
|
+
}: TimelineProps) {
|
|
52
|
+
const [playing, setPlaying] = useState(false);
|
|
53
|
+
const [speed, setSpeed] = useState<Speed>(1);
|
|
54
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
55
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const total = trace?.events.length ?? 0;
|
|
57
|
+
const max = Math.max(0, total - 1);
|
|
58
|
+
const seq = Math.min(currentSeq, max);
|
|
59
|
+
const event = trace?.events[seq];
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setPlaying(false);
|
|
63
|
+
}, [trace]);
|
|
64
|
+
|
|
65
|
+
// while a video export is recording, the recorder owns the playhead — force
|
|
66
|
+
// playback off so the Timeline timer can't fight it for currentSeq
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (exporting) setPlaying(false);
|
|
69
|
+
}, [exporting]);
|
|
70
|
+
|
|
71
|
+
// the timer and shortcuts read position via refs so ticking doesn't tear them down
|
|
72
|
+
const seqRef = useRef(seq);
|
|
73
|
+
const maxRef = useRef(max);
|
|
74
|
+
const playingRef = useRef(playing);
|
|
75
|
+
seqRef.current = seq;
|
|
76
|
+
maxRef.current = max;
|
|
77
|
+
playingRef.current = playing;
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!playing || total === 0 || exporting) return;
|
|
81
|
+
// higher speeds keep the render rate bounded by advancing several events per tick
|
|
82
|
+
const interval = Math.max(85, BASE_TICK_MS / speed);
|
|
83
|
+
const step = Math.max(1, Math.round((speed * interval) / BASE_TICK_MS));
|
|
84
|
+
const timer = window.setInterval(() => {
|
|
85
|
+
if (seqRef.current >= maxRef.current) {
|
|
86
|
+
setPlaying(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onChange(Math.min(seqRef.current + step, maxRef.current));
|
|
90
|
+
}, interval);
|
|
91
|
+
return () => window.clearInterval(timer);
|
|
92
|
+
}, [playing, speed, total, onChange, exporting]);
|
|
93
|
+
|
|
94
|
+
const togglePlay = useCallback(() => {
|
|
95
|
+
if (!playingRef.current && seqRef.current >= maxRef.current) onChange(0);
|
|
96
|
+
setPlaying((v) => !v);
|
|
97
|
+
}, [onChange]);
|
|
98
|
+
|
|
99
|
+
const cycleSpeed = useCallback(() => {
|
|
100
|
+
setSpeed((s) => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length]);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const step = useCallback(
|
|
104
|
+
(delta: number) => {
|
|
105
|
+
onChange(Math.min(maxRef.current, Math.max(0, seqRef.current + delta)));
|
|
106
|
+
},
|
|
107
|
+
[onChange]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const jumpEvent = useCallback(
|
|
111
|
+
(dir: 1 | -1, pred: (e: TraceEvent) => boolean) => {
|
|
112
|
+
if (!trace) return;
|
|
113
|
+
for (let i = seqRef.current + dir; i >= 0 && i < trace.events.length; i += dir) {
|
|
114
|
+
if (pred(trace.events[i])) {
|
|
115
|
+
onChange(i);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[trace, onChange]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const markSeqs = useMemo(() => {
|
|
124
|
+
const clamped = (trace?.marks ?? []).map((mark) => Math.min(mark.seq, Math.max(0, (trace?.events.length ?? 1) - 1)));
|
|
125
|
+
return [...new Set(clamped)].sort((a, b) => a - b);
|
|
126
|
+
}, [trace]);
|
|
127
|
+
|
|
128
|
+
const jumpMark = useCallback(
|
|
129
|
+
(dir: 1 | -1) => {
|
|
130
|
+
const cur = seqRef.current;
|
|
131
|
+
const next = dir === 1 ? markSeqs.find((s) => s > cur) : [...markSeqs].reverse().find((s) => s < cur);
|
|
132
|
+
if (next !== undefined) onChange(next);
|
|
133
|
+
},
|
|
134
|
+
[markSeqs, onChange]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// playback shortcuts; scene and rail keep their own (⌘B lives in App).
|
|
138
|
+
// suspended while exporting so a keypress can't move the recorded playhead.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!trace || exporting) return;
|
|
141
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
142
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
143
|
+
const target = e.target as HTMLElement | null;
|
|
144
|
+
if (target?.closest("input, textarea, select, button, [contenteditable]")) return;
|
|
145
|
+
switch (e.key) {
|
|
146
|
+
case " ":
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
togglePlay();
|
|
149
|
+
return;
|
|
150
|
+
case "ArrowLeft":
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
step(e.shiftKey ? -10 : -1);
|
|
153
|
+
return;
|
|
154
|
+
case "ArrowRight":
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
step(e.shiftKey ? 10 : 1);
|
|
157
|
+
return;
|
|
158
|
+
case "Home":
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
onChange(0);
|
|
161
|
+
return;
|
|
162
|
+
case "End":
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
onChange(maxRef.current);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
switch (e.key.toLowerCase()) {
|
|
168
|
+
case "s":
|
|
169
|
+
cycleSpeed();
|
|
170
|
+
return;
|
|
171
|
+
case "e":
|
|
172
|
+
jumpEvent(e.shiftKey ? -1 : 1, (ev) => ev.action === "edit");
|
|
173
|
+
return;
|
|
174
|
+
case "x":
|
|
175
|
+
jumpEvent(e.shiftKey ? -1 : 1, (ev) => ev.isError);
|
|
176
|
+
return;
|
|
177
|
+
case "m":
|
|
178
|
+
jumpMark(e.shiftKey ? -1 : 1);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
window.addEventListener("keydown", onKeyDown);
|
|
183
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
184
|
+
}, [trace, exporting, togglePlay, step, onChange, cycleSpeed, jumpEvent, jumpMark]);
|
|
185
|
+
|
|
186
|
+
// transport + scrubber are inert with no trace, or while an export owns the playhead
|
|
187
|
+
const locked = total === 0 || exporting;
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (locked) setMenuOpen(false);
|
|
191
|
+
}, [locked]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!menuOpen) return;
|
|
195
|
+
const onDown = (e: PointerEvent) => {
|
|
196
|
+
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
|
197
|
+
};
|
|
198
|
+
const onKey = (e: KeyboardEvent) => {
|
|
199
|
+
if (e.key === "Escape") setMenuOpen(false);
|
|
200
|
+
};
|
|
201
|
+
window.addEventListener("pointerdown", onDown);
|
|
202
|
+
window.addEventListener("keydown", onKey);
|
|
203
|
+
return () => {
|
|
204
|
+
window.removeEventListener("pointerdown", onDown);
|
|
205
|
+
window.removeEventListener("keydown", onKey);
|
|
206
|
+
};
|
|
207
|
+
}, [menuOpen]);
|
|
208
|
+
|
|
209
|
+
const buckets = useMemo<Bucket[]>(() => {
|
|
210
|
+
if (!trace || total === 0) return [];
|
|
211
|
+
const n = Math.min(BUCKETS, total);
|
|
212
|
+
const out: Bucket[] = [];
|
|
213
|
+
for (let b = 0; b < n; b++) {
|
|
214
|
+
const from = Math.floor((b * total) / n);
|
|
215
|
+
const to = Math.floor(((b + 1) * total) / n);
|
|
216
|
+
const byAction = new Map<Action, number>();
|
|
217
|
+
for (let i = from; i < to; i++) {
|
|
218
|
+
const action = trace.events[i].action;
|
|
219
|
+
byAction.set(action, (byAction.get(action) ?? 0) + 1);
|
|
220
|
+
}
|
|
221
|
+
let dominant: Action = "other";
|
|
222
|
+
let best = -1;
|
|
223
|
+
// edits are rare but load-bearing: let them win the bucket color on ties
|
|
224
|
+
const priority: Action[] = ["edit", "verify", "read", "search", "exec", "other"];
|
|
225
|
+
for (const action of priority) {
|
|
226
|
+
const count = byAction.get(action) ?? 0;
|
|
227
|
+
if (count > best) {
|
|
228
|
+
best = count;
|
|
229
|
+
dominant = action;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
out.push({ count: to - from, dominant });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}, [trace, total]);
|
|
236
|
+
|
|
237
|
+
const peak = useMemo(() => buckets.reduce((acc, b) => Math.max(acc, b.count), 1), [buckets]);
|
|
238
|
+
|
|
239
|
+
const markGroups = useMemo<MarkGroup[]>(() => {
|
|
240
|
+
if (!trace) return [];
|
|
241
|
+
const groups = new Map<string, MarkGroup>();
|
|
242
|
+
for (const mark of trace.marks) {
|
|
243
|
+
// tail marks can carry seq == events.length; clamp keeps them on the strip
|
|
244
|
+
const seqC = Math.min(mark.seq, max);
|
|
245
|
+
const pos = max > 0 ? seqC / max : 0;
|
|
246
|
+
const key = `${mark.type}:${Math.round(pos * MARK_SLOTS)}`;
|
|
247
|
+
const group = groups.get(key);
|
|
248
|
+
if (group) {
|
|
249
|
+
group.count++;
|
|
250
|
+
group.seq = Math.min(group.seq, seqC);
|
|
251
|
+
} else {
|
|
252
|
+
groups.set(key, { type: mark.type, seq: seqC, pos, count: 1, note: mark.note });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...groups.values()];
|
|
256
|
+
}, [trace, max]);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<footer className="deck">
|
|
260
|
+
<div className="deck-main">
|
|
261
|
+
<div className="strip">
|
|
262
|
+
<div className="strip-marks">
|
|
263
|
+
{markGroups.map((group, i) => (
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
key={`${group.type}-${group.seq}-${i}`}
|
|
267
|
+
className={`strip-mark ${group.type}`}
|
|
268
|
+
style={{ left: `${group.pos * 100}%` }}
|
|
269
|
+
disabled={locked}
|
|
270
|
+
title={`${group.note || MARK_LABEL[group.type]}${group.count > 1 ? ` ×${group.count}` : ""}`}
|
|
271
|
+
aria-label={`Jump to ${group.note || MARK_LABEL[group.type]} at event ${group.seq + 1}${group.count > 1 ? `, ${group.count} marks` : ""}`}
|
|
272
|
+
onClick={() =>
|
|
273
|
+
group.type === "subagent" && onSubagentMark
|
|
274
|
+
? onSubagentMark(group.seq)
|
|
275
|
+
: onChange(group.seq)
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
<div className="strip-bars" aria-hidden>
|
|
281
|
+
{buckets.map((bucket, i) => (
|
|
282
|
+
<span
|
|
283
|
+
key={i}
|
|
284
|
+
className={`strip-bar ${bucket.dominant}`}
|
|
285
|
+
style={{ height: `${18 + (bucket.count / peak) * 82}%` }}
|
|
286
|
+
/>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
{total > 0 ? (
|
|
290
|
+
<div className="strip-playhead" style={{ left: `${(seq / Math.max(max, 1)) * 100}%` }} aria-hidden />
|
|
291
|
+
) : null}
|
|
292
|
+
<input
|
|
293
|
+
className="strip-input"
|
|
294
|
+
type="range"
|
|
295
|
+
min={0}
|
|
296
|
+
max={max}
|
|
297
|
+
value={seq}
|
|
298
|
+
disabled={locked}
|
|
299
|
+
onChange={(e) => onChange(Number(e.currentTarget.value))}
|
|
300
|
+
aria-label="Playback position"
|
|
301
|
+
aria-valuetext={event ? `event ${event.seq}: ${event.tool}` : "empty"}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="deck-pos">
|
|
306
|
+
{/* reserve the widest count for this session ("599 / 599") so the
|
|
307
|
+
ticking digits never resize the strip beside them */}
|
|
308
|
+
<span className="deck-pos-count" style={{ minWidth: `${String(Math.max(total, 1)).length * 2 + 3}ch` }}>
|
|
309
|
+
{total > 0 ? `${seq + 1} / ${total}` : "0 / 0"}
|
|
310
|
+
</span>
|
|
311
|
+
<span className="deck-pos-clock">{event?.ts ? clock(event.ts) : "—"}</span>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div className="transport">
|
|
315
|
+
<button
|
|
316
|
+
className="icon-btn"
|
|
317
|
+
onClick={() => step(-1)}
|
|
318
|
+
disabled={locked}
|
|
319
|
+
title="Step back (←)"
|
|
320
|
+
aria-label="Step back one event"
|
|
321
|
+
>
|
|
322
|
+
<StepBack size={15} />
|
|
323
|
+
</button>
|
|
324
|
+
<button
|
|
325
|
+
className="play-btn"
|
|
326
|
+
onClick={togglePlay}
|
|
327
|
+
disabled={locked}
|
|
328
|
+
title={playing ? "Pause (Space)" : "Play (Space)"}
|
|
329
|
+
aria-label={playing ? "Pause playback" : "Play playback"}
|
|
330
|
+
>
|
|
331
|
+
{playing ? <Pause size={16} /> : <Play size={16} className="play-glyph" />}
|
|
332
|
+
</button>
|
|
333
|
+
<button
|
|
334
|
+
className="icon-btn"
|
|
335
|
+
onClick={() => step(1)}
|
|
336
|
+
disabled={locked}
|
|
337
|
+
title="Step forward (→)"
|
|
338
|
+
aria-label="Step forward one event"
|
|
339
|
+
>
|
|
340
|
+
<StepForward size={15} />
|
|
341
|
+
</button>
|
|
342
|
+
<div className="transport-more" ref={menuRef}>
|
|
343
|
+
{/* the trigger doubles as status: recording spinner while an export
|
|
344
|
+
runs, the engaged speed when it isn't 1× */}
|
|
345
|
+
<button
|
|
346
|
+
className={`icon-btn${exporting ? " recording" : ""}${menuOpen ? " open" : ""}`}
|
|
347
|
+
onClick={() => setMenuOpen((v) => !v)}
|
|
348
|
+
disabled={locked}
|
|
349
|
+
aria-haspopup="true"
|
|
350
|
+
aria-expanded={menuOpen}
|
|
351
|
+
title="More controls"
|
|
352
|
+
aria-label="More playback controls"
|
|
353
|
+
>
|
|
354
|
+
{exporting ? (
|
|
355
|
+
<Loader size={15} className="spin" />
|
|
356
|
+
) : speed !== 1 ? (
|
|
357
|
+
<span className="more-speed">{speed}×</span>
|
|
358
|
+
) : (
|
|
359
|
+
<Ellipsis size={15} />
|
|
360
|
+
)}
|
|
361
|
+
</button>
|
|
362
|
+
{menuOpen ? (
|
|
363
|
+
<div className="transport-pop">
|
|
364
|
+
<div className="pop-speed">
|
|
365
|
+
<span className="pop-speed-label">Speed</span>
|
|
366
|
+
<div className="pop-speed-chips" title="Cycle with S">
|
|
367
|
+
{SPEEDS.map((s) => (
|
|
368
|
+
<button
|
|
369
|
+
key={s}
|
|
370
|
+
className={s === speed ? "pop-chip engaged" : "pop-chip"}
|
|
371
|
+
onClick={() => setSpeed(s)}
|
|
372
|
+
aria-pressed={s === speed}
|
|
373
|
+
>
|
|
374
|
+
{s}×
|
|
375
|
+
</button>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
<button
|
|
380
|
+
className="pop-item"
|
|
381
|
+
onClick={() => {
|
|
382
|
+
onChange(0);
|
|
383
|
+
setMenuOpen(false);
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<RotateCcw size={14} />
|
|
387
|
+
<span>Restart</span>
|
|
388
|
+
<kbd>Home</kbd>
|
|
389
|
+
</button>
|
|
390
|
+
{onExport ? (
|
|
391
|
+
<button
|
|
392
|
+
className="pop-item"
|
|
393
|
+
onClick={() => {
|
|
394
|
+
setMenuOpen(false);
|
|
395
|
+
onExport();
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<Video size={14} />
|
|
399
|
+
<span>Export video</span>
|
|
400
|
+
</button>
|
|
401
|
+
) : null}
|
|
402
|
+
</div>
|
|
403
|
+
) : null}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<div className="deck-foot">
|
|
409
|
+
<div className="readout-now">
|
|
410
|
+
{event ? (
|
|
411
|
+
<>
|
|
412
|
+
<span className={`action-dot ${event.action}`} />
|
|
413
|
+
<span className="readout-tool">{event.tool}</span>
|
|
414
|
+
{event.isError ? <span className="err">error</span> : null}
|
|
415
|
+
<span className="readout-summary" title={event.summary}>
|
|
416
|
+
{event.summary}
|
|
417
|
+
</span>
|
|
418
|
+
</>
|
|
419
|
+
) : (
|
|
420
|
+
<span className="readout-summary">
|
|
421
|
+
{trace ? "No recorded activity for this agent." : "Select a session to start the walk."}
|
|
422
|
+
</span>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
<div className="deck-legend" aria-hidden>
|
|
426
|
+
<span className="legend-group">
|
|
427
|
+
{STRIP_ACTIONS.map((action) => (
|
|
428
|
+
<span key={action} className="legend-item">
|
|
429
|
+
<span className={`action-dot ${action}`} />
|
|
430
|
+
{action}
|
|
431
|
+
</span>
|
|
432
|
+
))}
|
|
433
|
+
</span>
|
|
434
|
+
<span className="legend-group">
|
|
435
|
+
<span className="legend-item">
|
|
436
|
+
<span className="legend-glyph compaction" />
|
|
437
|
+
compaction
|
|
438
|
+
</span>
|
|
439
|
+
<span className="legend-item">
|
|
440
|
+
<span className="legend-glyph subagent" />
|
|
441
|
+
subagent
|
|
442
|
+
</span>
|
|
443
|
+
<span className="legend-item">
|
|
444
|
+
<span className="legend-glyph user-message" />
|
|
445
|
+
user turn
|
|
446
|
+
</span>
|
|
447
|
+
</span>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</footer>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function clock(iso: string): string {
|
|
455
|
+
const d = new Date(iso);
|
|
456
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
457
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
458
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Mountain, TreePine } from "lucide-react";
|
|
2
|
+
import type { SceneView } from "../state/store";
|
|
3
|
+
|
|
4
|
+
interface ViewPanelProps {
|
|
5
|
+
view: SceneView;
|
|
6
|
+
onViewChange: (view: SceneView) => void;
|
|
7
|
+
/** the legend line for the active view ("glow ∝ revisits", …) */
|
|
8
|
+
note: string;
|
|
9
|
+
/** during video export the scene canvas must not be swapped */
|
|
10
|
+
locked?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// pop content for the dock's scene section: how the stage renders. Future
|
|
14
|
+
// layer toggles (line-level lights, trail length, floor contrast) join this
|
|
15
|
+
// panel instead of growing the strip.
|
|
16
|
+
export function ViewPanel({ view, onViewChange, note, locked = false }: ViewPanelProps) {
|
|
17
|
+
return (
|
|
18
|
+
// toggle buttons rather than a radiogroup: the full radio pattern would
|
|
19
|
+
// demand roving tabindex + arrow-key navigation these buttons don't have
|
|
20
|
+
<div className="view-pop" role="group" aria-label="Scene view">
|
|
21
|
+
<button
|
|
22
|
+
aria-pressed={view === "tree"}
|
|
23
|
+
className={view === "tree" ? "view-row active" : "view-row"}
|
|
24
|
+
onClick={() => onViewChange("tree")}
|
|
25
|
+
disabled={locked}
|
|
26
|
+
>
|
|
27
|
+
<TreePine size={14} />
|
|
28
|
+
<span>Tree</span>
|
|
29
|
+
</button>
|
|
30
|
+
<button
|
|
31
|
+
aria-pressed={view === "terrain"}
|
|
32
|
+
className={view === "terrain" ? "view-row active" : "view-row"}
|
|
33
|
+
onClick={() => onViewChange("terrain")}
|
|
34
|
+
disabled={locked}
|
|
35
|
+
>
|
|
36
|
+
<Mountain size={14} />
|
|
37
|
+
<span>Terrain</span>
|
|
38
|
+
</button>
|
|
39
|
+
<p className="view-note">
|
|
40
|
+
{note}
|
|
41
|
+
<span className="view-key">V</span>
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "Bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"],
|
|
20
|
+
"references": []
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
server: {
|
|
7
|
+
host: "127.0.0.1",
|
|
8
|
+
port: 5173,
|
|
9
|
+
proxy: {
|
|
10
|
+
"/api": "http://127.0.0.1:8765"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
build: {
|
|
14
|
+
sourcemap: true,
|
|
15
|
+
// three.js alone minifies to ~510 kB (~130 kB gzip) and cannot be split
|
|
16
|
+
// further, so give it its own chunk and lift the warning limit above it
|
|
17
|
+
chunkSizeWarningLimit: 600,
|
|
18
|
+
rollupOptions: {
|
|
19
|
+
output: {
|
|
20
|
+
manualChunks(id) {
|
|
21
|
+
if (id.includes("node_modules/three/")) return "three";
|
|
22
|
+
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return "react";
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|