@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,104 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export type PanelPresentation = "sheet" | "pop";
|
|
5
|
+
export type PanelSection = "scene" | "session";
|
|
6
|
+
export type PanelBadge = "running" | "done" | "stale" | "failed";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One dock panel, declared as data. Extending the dock — a layers toggle, a
|
|
10
|
+
* compare view, a metrics board — means registering one descriptor here;
|
|
11
|
+
* the strip, grouping, badges, and open/close behavior come for free.
|
|
12
|
+
*/
|
|
13
|
+
export interface PanelDescriptor {
|
|
14
|
+
id: string;
|
|
15
|
+
icon: LucideIcon;
|
|
16
|
+
hint: string;
|
|
17
|
+
/** scene = how the stage renders; session = depth content about the trace */
|
|
18
|
+
section: PanelSection;
|
|
19
|
+
/** sheet = full-height paper, one at a time; pop = compact card anchored
|
|
20
|
+
* to the strip, coexists with an open sheet */
|
|
21
|
+
presentation: PanelPresentation;
|
|
22
|
+
badge?: PanelBadge | null;
|
|
23
|
+
render: () => ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DockProps {
|
|
27
|
+
panels: PanelDescriptor[];
|
|
28
|
+
openSheet: string | null;
|
|
29
|
+
openPop: string | null;
|
|
30
|
+
onToggle: (panel: PanelDescriptor) => void;
|
|
31
|
+
onClosePop: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Dock({ panels, openSheet, openPop, onToggle, onClosePop }: DockProps) {
|
|
35
|
+
const popRef = useRef<HTMLDivElement | null>(null);
|
|
36
|
+
const stripRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
+
const sheet = panels.find((panel) => panel.id === openSheet && panel.presentation === "sheet");
|
|
38
|
+
const pop = panels.find((panel) => panel.id === openPop && panel.presentation === "pop");
|
|
39
|
+
const sections: PanelSection[] = ["scene", "session"];
|
|
40
|
+
const grouped = sections
|
|
41
|
+
.map((section) => ({ section, items: panels.filter((panel) => panel.section === section) }))
|
|
42
|
+
.filter((group) => group.items.length > 0);
|
|
43
|
+
|
|
44
|
+
// pops are transient: click-away or Escape dismisses, like any menu; the
|
|
45
|
+
// strip is exempt so the toggle button doesn't dismiss-then-reopen
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!pop) return;
|
|
48
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
49
|
+
const target = event.target as Node;
|
|
50
|
+
if (popRef.current?.contains(target) || stripRef.current?.contains(target)) return;
|
|
51
|
+
onClosePop();
|
|
52
|
+
};
|
|
53
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
54
|
+
if (event.key === "Escape") onClosePop();
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
57
|
+
document.addEventListener("keydown", onKeyDown);
|
|
58
|
+
return () => {
|
|
59
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
60
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
61
|
+
};
|
|
62
|
+
}, [pop, onClosePop]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="dock">
|
|
66
|
+
{/* anchored left of the whole dock: beside the sheet when one is open,
|
|
67
|
+
beside the strip otherwise — never covering either */}
|
|
68
|
+
{pop ? (
|
|
69
|
+
<div className="dock-pop" ref={popRef}>
|
|
70
|
+
{pop.render()}
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
{sheet ? <aside className="dock-panel">{sheet.render()}</aside> : null}
|
|
74
|
+
<div className="dock-side" ref={stripRef}>
|
|
75
|
+
{/* toggle buttons, not tabs: panels open as pops or sheets with no
|
|
76
|
+
tabpanel relationship, so aria-pressed is the honest semantic */}
|
|
77
|
+
<div className="dock-strip" role="group" aria-label="Stage panels">
|
|
78
|
+
{grouped.map((group, index) => (
|
|
79
|
+
<div key={group.section} className="dock-strip-group">
|
|
80
|
+
{index > 0 ? <div className="dock-strip-divider" aria-hidden /> : null}
|
|
81
|
+
{group.items.map((panel) => {
|
|
82
|
+
const active = panel.id === openSheet || panel.id === openPop;
|
|
83
|
+
const Icon = panel.icon;
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
key={panel.id}
|
|
87
|
+
aria-pressed={active}
|
|
88
|
+
className={active ? "active" : ""}
|
|
89
|
+
onClick={() => onToggle(panel)}
|
|
90
|
+
data-hint={panel.hint}
|
|
91
|
+
aria-label={panel.hint}
|
|
92
|
+
>
|
|
93
|
+
<Icon size={15} />
|
|
94
|
+
{panel.badge ? <span className={`dock-dot dock-dot-${panel.badge}`} /> : null}
|
|
95
|
+
</button>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ActionCounts, CityMap, MetricObservability, Trace } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface ChurnEntry {
|
|
5
|
+
path: string;
|
|
6
|
+
edits: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface HudProps {
|
|
10
|
+
trace?: Trace;
|
|
11
|
+
city?: CityMap;
|
|
12
|
+
agentLabel?: string;
|
|
13
|
+
// live counts at the playhead, passed as primitives so memo stays effective
|
|
14
|
+
editedNow: number;
|
|
15
|
+
readNow: number;
|
|
16
|
+
seenNow: number;
|
|
17
|
+
churn: ChurnEntry[];
|
|
18
|
+
onSelectFile: (path: string) => void;
|
|
19
|
+
onOpenAgents?: () => void;
|
|
20
|
+
locked?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CHURN_PANEL_ROWS = 8;
|
|
24
|
+
|
|
25
|
+
// memo: the app re-renders every playback tick; the HUD only changes when the
|
|
26
|
+
// session, the view toggle, or the touch counts under the playhead change
|
|
27
|
+
export const Hud = memo(function Hud({
|
|
28
|
+
trace,
|
|
29
|
+
city,
|
|
30
|
+
agentLabel,
|
|
31
|
+
editedNow,
|
|
32
|
+
readNow,
|
|
33
|
+
seenNow,
|
|
34
|
+
churn,
|
|
35
|
+
onSelectFile,
|
|
36
|
+
onOpenAgents,
|
|
37
|
+
locked = false
|
|
38
|
+
}: HudProps) {
|
|
39
|
+
const stats = trace?.stats;
|
|
40
|
+
const readFinal = stats ? stats.fovea - stats.edited : 0;
|
|
41
|
+
const unvisitedNow = stats ? Math.max(0, stats.filesInRepo - editedNow - readNow - seenNow) : 0;
|
|
42
|
+
const unvisitedFinal = stats ? Math.max(0, stats.filesInRepo - stats.fovea - stats.parafovea) : 0;
|
|
43
|
+
const ghostCount = city ? city.files.reduce((n, file) => n + (file.ghost ? 1 : 0), 0) : 0;
|
|
44
|
+
const errorCount = stats ? countActions(stats.errors) : 0;
|
|
45
|
+
const showReview = stats ? errorCount > 0 || stats.churnFiles > 0 || stats.actions.edit > 0 : false;
|
|
46
|
+
|
|
47
|
+
const [churnOpen, setChurnOpen] = useState(false);
|
|
48
|
+
const churnPanelRef = useRef<HTMLDivElement | null>(null);
|
|
49
|
+
const churnToggleRef = useRef<HTMLButtonElement | null>(null);
|
|
50
|
+
|
|
51
|
+
// a new session invalidates the open list; close instead of showing stale rows
|
|
52
|
+
useEffect(() => setChurnOpen(false), [trace]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!churnOpen) return;
|
|
56
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
57
|
+
const target = event.target as Node;
|
|
58
|
+
if (churnPanelRef.current?.contains(target) || churnToggleRef.current?.contains(target)) return;
|
|
59
|
+
setChurnOpen(false);
|
|
60
|
+
};
|
|
61
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
62
|
+
if (event.key === "Escape") setChurnOpen(false);
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
65
|
+
document.addEventListener("keydown", onKeyDown);
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
68
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
69
|
+
};
|
|
70
|
+
}, [churnOpen]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="hud" aria-hidden={!city}>
|
|
74
|
+
<div className="hud-left">
|
|
75
|
+
<div className="hud-title-line">
|
|
76
|
+
<div className="hud-repo">{city ? basename(city.repo.root) : ""}</div>
|
|
77
|
+
{trace && agentLabel ? (
|
|
78
|
+
<button
|
|
79
|
+
className="hud-lens"
|
|
80
|
+
onClick={onOpenAgents}
|
|
81
|
+
disabled={!onOpenAgents || locked}
|
|
82
|
+
aria-label={`Open Agent lenses, current ${agentLabel}`}
|
|
83
|
+
>
|
|
84
|
+
<span>Lens</span>
|
|
85
|
+
{agentLabel}
|
|
86
|
+
</button>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
{city ? (
|
|
90
|
+
<div className="hud-commit">
|
|
91
|
+
<span>{city.repo.commit || "worktree"}</span>
|
|
92
|
+
{city.repo.dirty ? <span className="dirty">● dirty</span> : null}
|
|
93
|
+
{trace?.session.model ? <span>{trace.session.model}</span> : null}
|
|
94
|
+
{stats ? (
|
|
95
|
+
<span data-hint="Files in the repository map — the denominator of the coverage spectrum below">
|
|
96
|
+
{stats.filesInRepo} files
|
|
97
|
+
</span>
|
|
98
|
+
) : null}
|
|
99
|
+
{city.repo.truncated ? (
|
|
100
|
+
<span data-hint="The tree holds more files than the map shows — scanning stopped at the budget">
|
|
101
|
+
partial map
|
|
102
|
+
</span>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
) : null}
|
|
106
|
+
{stats ? (
|
|
107
|
+
<>
|
|
108
|
+
{/* the spectrum doubles as scene legend and live tally: each entry is
|
|
109
|
+
a touch state, counted at the playhead → across the whole walk */}
|
|
110
|
+
<div className="spectrum">
|
|
111
|
+
<SpectrumStat
|
|
112
|
+
kind="edit"
|
|
113
|
+
label="edited"
|
|
114
|
+
now={editedNow}
|
|
115
|
+
final={stats.edited}
|
|
116
|
+
hint="Files the agent changed"
|
|
117
|
+
/>
|
|
118
|
+
<SpectrumStat
|
|
119
|
+
kind="read"
|
|
120
|
+
label="read"
|
|
121
|
+
now={readNow}
|
|
122
|
+
final={readFinal}
|
|
123
|
+
hint="Files the agent opened and read, but never changed"
|
|
124
|
+
/>
|
|
125
|
+
<SpectrumStat
|
|
126
|
+
kind="hit"
|
|
127
|
+
label="seen"
|
|
128
|
+
now={seenNow}
|
|
129
|
+
final={stats.parafovea}
|
|
130
|
+
hint="Files that only appeared in search results, never opened"
|
|
131
|
+
/>
|
|
132
|
+
<SpectrumStat
|
|
133
|
+
kind="unvisited"
|
|
134
|
+
label="unvisited"
|
|
135
|
+
now={unvisitedNow}
|
|
136
|
+
final={unvisitedFinal}
|
|
137
|
+
hint="Files in the map the agent never touched"
|
|
138
|
+
/>
|
|
139
|
+
{ghostCount > 0 ? (
|
|
140
|
+
<SpectrumStat
|
|
141
|
+
kind="ghost"
|
|
142
|
+
label="ghost"
|
|
143
|
+
now={ghostCount}
|
|
144
|
+
final={ghostCount}
|
|
145
|
+
hint="Files the session touched that are gone from the repository — drawn hollow in the scene"
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
{/* session scale: quiet background numbers, final totals only —
|
|
150
|
+
unlike the playhead-live spectrum above */}
|
|
151
|
+
<div className="hud-quiet">
|
|
152
|
+
<span data-hint={`Tool calls — ${mixHint(stats.actions)}`}>
|
|
153
|
+
{countActions(stats.actions)} calls
|
|
154
|
+
</span>
|
|
155
|
+
<span data-hint="User messages — each one starts a turn of agent work">
|
|
156
|
+
{stats.userTurns} turns
|
|
157
|
+
</span>
|
|
158
|
+
{stats.subagents > 0 ? (
|
|
159
|
+
<button
|
|
160
|
+
className="hud-agent-link"
|
|
161
|
+
data-hint="Subagent launches (Task/Agent) — open Agent lenses"
|
|
162
|
+
onClick={onOpenAgents}
|
|
163
|
+
disabled={!onOpenAgents || locked}
|
|
164
|
+
aria-label={`Open ${stats.subagents} subagent${stats.subagents === 1 ? "" : "s"} in Agent lenses`}
|
|
165
|
+
>
|
|
166
|
+
{stats.subagents} subagent{stats.subagents === 1 ? "" : "s"}
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
169
|
+
{stats.compactions > 0 ? (
|
|
170
|
+
<span data-hint="Context compactions — the conversation was summarized to free memory">
|
|
171
|
+
{stats.compactions} compaction{stats.compactions === 1 ? "" : "s"}
|
|
172
|
+
</span>
|
|
173
|
+
) : null}
|
|
174
|
+
<span data-hint="Tool output the agent consumed over the session">
|
|
175
|
+
{fmtBytes(stats.resultBytes)} output
|
|
176
|
+
</span>
|
|
177
|
+
<span data-hint={rereadHint(stats.observability.reads)}>
|
|
178
|
+
{stats.observability.reads === "unavailable"
|
|
179
|
+
? "re-reads n/a"
|
|
180
|
+
: `re-reads ${approx(stats.observability.reads)}${pct(stats.regressionRate)}`}
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
{/* review: only signals worth a second look — absent when clean */}
|
|
184
|
+
{showReview ? (
|
|
185
|
+
<div className="hud-review">
|
|
186
|
+
<span className="review-label">review</span>
|
|
187
|
+
{errorCount > 0 ? (
|
|
188
|
+
<span
|
|
189
|
+
className="warn"
|
|
190
|
+
data-hint={`${mixHint(stats.errors)} — press X to jump to the next one${errorCaveat(stats.observability.errors)}`}
|
|
191
|
+
>
|
|
192
|
+
{approx(stats.observability.errors)}
|
|
193
|
+
{errorCount} error{errorCount === 1 ? "" : "s"}
|
|
194
|
+
</span>
|
|
195
|
+
) : null}
|
|
196
|
+
{stats.churnFiles > 0 ? (
|
|
197
|
+
<button
|
|
198
|
+
ref={churnToggleRef}
|
|
199
|
+
className={churnOpen ? "warn churn-toggle open" : "warn churn-toggle"}
|
|
200
|
+
aria-expanded={churnOpen}
|
|
201
|
+
onClick={() => setChurnOpen((open) => !open)}
|
|
202
|
+
data-hint={`Files edited in three or more separate events — the most-edited file changed ${stats.maxEditsPerFile} times. Click to list them.`}
|
|
203
|
+
>
|
|
204
|
+
{stats.churnFiles} file{stats.churnFiles === 1 ? "" : "s"} edited 3+ times
|
|
205
|
+
</button>
|
|
206
|
+
) : null}
|
|
207
|
+
{stats.actions.edit > 0 ? (
|
|
208
|
+
stats.actions.verify === 0 ? (
|
|
209
|
+
<span
|
|
210
|
+
className="warn"
|
|
211
|
+
data-hint="The session edited files but no build or test commands were recognized (go test, make test, npm run build, …)"
|
|
212
|
+
>
|
|
213
|
+
never verified
|
|
214
|
+
</span>
|
|
215
|
+
) : stats.editsAfterLastVerify > 0 ? (
|
|
216
|
+
<span
|
|
217
|
+
className="warn"
|
|
218
|
+
data-hint={`Edit events after the session's last build or test run — ${verifyRuns(stats.actions.verify)} total; pass/fail is not tracked`}
|
|
219
|
+
>
|
|
220
|
+
{stats.editsAfterLastVerify} edit{stats.editsAfterLastVerify === 1 ? "" : "s"} after
|
|
221
|
+
last verify
|
|
222
|
+
</span>
|
|
223
|
+
) : (
|
|
224
|
+
<span
|
|
225
|
+
className="ok"
|
|
226
|
+
data-hint={`The last edit was followed by a build or test run — ${verifyRuns(stats.actions.verify)} total; pass/fail is not tracked`}
|
|
227
|
+
>
|
|
228
|
+
verified after final edit
|
|
229
|
+
</span>
|
|
230
|
+
)
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
) : null}
|
|
234
|
+
{churnOpen ? (
|
|
235
|
+
<div className="churn-panel" ref={churnPanelRef}>
|
|
236
|
+
{churn.slice(0, CHURN_PANEL_ROWS).map((entry) => (
|
|
237
|
+
<button
|
|
238
|
+
key={entry.path}
|
|
239
|
+
className="churn-row"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
onSelectFile(entry.path);
|
|
242
|
+
setChurnOpen(false);
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<span className="churn-path">{entry.path}</span>
|
|
246
|
+
<span className="churn-count">
|
|
247
|
+
{entry.edits} edit{entry.edits === 1 ? "" : "s"}
|
|
248
|
+
</span>
|
|
249
|
+
</button>
|
|
250
|
+
))}
|
|
251
|
+
{churn.length > CHURN_PANEL_ROWS ? (
|
|
252
|
+
<p className="churn-more">…and {churn.length - CHURN_PANEL_ROWS} more</p>
|
|
253
|
+
) : null}
|
|
254
|
+
</div>
|
|
255
|
+
) : null}
|
|
256
|
+
</>
|
|
257
|
+
) : null}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
function SpectrumStat({
|
|
264
|
+
kind,
|
|
265
|
+
label,
|
|
266
|
+
now,
|
|
267
|
+
final,
|
|
268
|
+
hint
|
|
269
|
+
}: {
|
|
270
|
+
kind: "edit" | "read" | "hit" | "unvisited" | "ghost";
|
|
271
|
+
label: string;
|
|
272
|
+
now: number;
|
|
273
|
+
final: number;
|
|
274
|
+
hint: string;
|
|
275
|
+
}) {
|
|
276
|
+
return (
|
|
277
|
+
<div className="spectrum-stat" data-hint={hint}>
|
|
278
|
+
<span className={`legend-dot ${kind}`} />
|
|
279
|
+
<span className="spectrum-label">{label}</span>
|
|
280
|
+
<strong>{now === final ? final : `${now} → ${final}`}</strong>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pct(rate: number): string {
|
|
286
|
+
return `${Math.round(rate * 100)}%`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function approx(observability: MetricObservability): string {
|
|
290
|
+
return observability === "estimated" ? "~" : "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function verifyRuns(count: number): string {
|
|
294
|
+
return `${count} verify run${count === 1 ? "" : "s"}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rereadHint(observability: MetricObservability): string {
|
|
298
|
+
switch (observability) {
|
|
299
|
+
case "unavailable":
|
|
300
|
+
return "No file reads observed — this session reads files through commands Synapse could not recognize";
|
|
301
|
+
case "estimated":
|
|
302
|
+
return "Reads that re-read a file unchanged since its last read — inferred from shell commands, so the rate is approximate";
|
|
303
|
+
default:
|
|
304
|
+
return "Reads that re-read a file unchanged since its last read";
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function errorCaveat(observability: MetricObservability): string {
|
|
309
|
+
return observability === "estimated"
|
|
310
|
+
? " — inferred from command output; failures inside scripted calls may be missed"
|
|
311
|
+
: "";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ACTION_ORDER = ["search", "read", "edit", "exec", "verify", "other"] as const;
|
|
315
|
+
|
|
316
|
+
function countActions(counts: ActionCounts): number {
|
|
317
|
+
return ACTION_ORDER.reduce((sum, key) => sum + counts[key], 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function mixHint(counts: ActionCounts): string {
|
|
321
|
+
const parts = ACTION_ORDER.filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${key}`);
|
|
322
|
+
return parts.length ? parts.join(" · ") : "none";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fmtBytes(bytes: number): string {
|
|
326
|
+
const kb = bytes / 1024;
|
|
327
|
+
if (kb < 1) return `${bytes} B`;
|
|
328
|
+
if (kb < 1000) return `${Math.round(kb)} KB`;
|
|
329
|
+
return `${(kb / 1024).toFixed(1)} MB`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function basename(path: string): string {
|
|
333
|
+
const clean = path.replace(/\/+$/, "");
|
|
334
|
+
return clean.slice(clean.lastIndexOf("/") + 1);
|
|
335
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { AlertTriangle, X } from "lucide-react";
|
|
2
|
+
import { touchWord, type CityFile, type Touch, type TraceEvent } from "../types";
|
|
3
|
+
|
|
4
|
+
interface InspectorProps {
|
|
5
|
+
/** absent when nothing is selected yet — renders the teaching empty state */
|
|
6
|
+
file?: CityFile;
|
|
7
|
+
touch?: Touch;
|
|
8
|
+
history: TraceEvent[];
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onJumpTo: (seq: number) => void;
|
|
11
|
+
locked?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// dock panel content: the selected file's identity, touch state, and visit
|
|
15
|
+
// history. The Dock owns positioning; this owns only its own markup.
|
|
16
|
+
export function Inspector({
|
|
17
|
+
file,
|
|
18
|
+
touch,
|
|
19
|
+
history,
|
|
20
|
+
onClose,
|
|
21
|
+
onJumpTo,
|
|
22
|
+
locked = false
|
|
23
|
+
}: InspectorProps) {
|
|
24
|
+
if (!file) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="dock-body" aria-label="File inspector">
|
|
27
|
+
<div className="inspector-head">
|
|
28
|
+
<div className="inspector-path">Inspect</div>
|
|
29
|
+
<button className="icon-btn" onClick={onClose} title="Close" aria-label="Close inspector">
|
|
30
|
+
<X size={15} />
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
<p className="dock-note">Click a building in the scene to inspect a file — its touch state, size, and every visit the agent paid it.</p>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const slash = file.path.lastIndexOf("/");
|
|
38
|
+
const dir = slash >= 0 ? file.path.slice(0, slash + 1) : "";
|
|
39
|
+
const name = slash >= 0 ? file.path.slice(slash + 1) : file.path;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="dock-body" aria-label={`File ${file.path}`}>
|
|
43
|
+
<div className="inspector-head">
|
|
44
|
+
<div>
|
|
45
|
+
<div className="inspector-path">
|
|
46
|
+
<span className="dir">{dir}</span>
|
|
47
|
+
{name}
|
|
48
|
+
</div>
|
|
49
|
+
{file.ghost ? <span className="ghost-badge">ghost — not in this tree</span> : null}
|
|
50
|
+
</div>
|
|
51
|
+
<button className="icon-btn" onClick={onClose} title="Close" aria-label="Close inspector">
|
|
52
|
+
<X size={15} />
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
<dl className="inspector-facts">
|
|
56
|
+
<div>
|
|
57
|
+
<dt>Touch</dt>
|
|
58
|
+
<dd className={touch ? `touch-${touch}` : undefined}>{touchWord(touch)}</dd>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<dt>Lang</dt>
|
|
62
|
+
<dd>{file.lang || "text"}</dd>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<dt>Lines</dt>
|
|
66
|
+
<dd>{file.lines.toLocaleString()}</dd>
|
|
67
|
+
</div>
|
|
68
|
+
<div>
|
|
69
|
+
<dt>Bytes</dt>
|
|
70
|
+
<dd>{file.bytes.toLocaleString()}</dd>
|
|
71
|
+
</div>
|
|
72
|
+
</dl>
|
|
73
|
+
<section className="inspector-history">
|
|
74
|
+
<p className="eyebrow">Visits · {history.length}</p>
|
|
75
|
+
<div className="history-list">
|
|
76
|
+
{history
|
|
77
|
+
.slice(-14)
|
|
78
|
+
.reverse()
|
|
79
|
+
.map((event) => (
|
|
80
|
+
<button
|
|
81
|
+
key={event.seq}
|
|
82
|
+
className="history-row"
|
|
83
|
+
onClick={() => onJumpTo(event.seq)}
|
|
84
|
+
disabled={locked}
|
|
85
|
+
title={`Jump to step ${event.seq + 1} — ${event.summary}`}
|
|
86
|
+
>
|
|
87
|
+
<span className={`action-dot ${event.action}`} />
|
|
88
|
+
<strong>#{event.seq + 1}</strong>
|
|
89
|
+
<span>{event.tool}</span>
|
|
90
|
+
<span className="history-time">{event.ts ? clock(event.ts) : ""}</span>
|
|
91
|
+
{event.isError ? <AlertTriangle className="history-err" size={13} /> : <span />}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
{history.length === 0 ? (
|
|
95
|
+
<p className="muted">Not visited yet at this point of the walk. Scrub the timeline forward.</p>
|
|
96
|
+
) : null}
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clock(iso: string): string {
|
|
104
|
+
const d = new Date(iso);
|
|
105
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
106
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The mark tells the product's story in one tile: a repository waiting in the
|
|
2
|
+
// dark, and an agent's walk lighting it up — footsteps of warm firefly light,
|
|
3
|
+
// touched nodes glowing by state (search moss, read moon, edit amber).
|
|
4
|
+
// Colors are literal so the mark stays identical to the favicon and README asset.
|
|
5
|
+
export function LogoMark({ size = 22 }: { size?: number }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg viewBox="0 0 64 64" width={size} height={size} aria-hidden focusable="false">
|
|
8
|
+
<defs>
|
|
9
|
+
<radialGradient id="logo-halo">
|
|
10
|
+
<stop offset="0%" stopColor="#f1aa57" stopOpacity="0.55" />
|
|
11
|
+
<stop offset="100%" stopColor="#f1aa57" stopOpacity="0" />
|
|
12
|
+
</radialGradient>
|
|
13
|
+
</defs>
|
|
14
|
+
<rect width="64" height="64" rx="14" fill="#0f141b" />
|
|
15
|
+
<circle cx="20" cy="15" r="1.6" fill="#3b4047" />
|
|
16
|
+
<circle cx="11" cy="27" r="1.4" fill="#3b4047" />
|
|
17
|
+
<circle cx="51" cy="40" r="1.6" fill="#3b4047" />
|
|
18
|
+
<circle cx="34" cy="53" r="1.4" fill="#3b4047" />
|
|
19
|
+
<g fill="#f1aa57">
|
|
20
|
+
<circle cx="18.6" cy="47.9" r="1.15" opacity="0.35" />
|
|
21
|
+
<circle cx="22.7" cy="45.2" r="1.25" opacity="0.42" />
|
|
22
|
+
<circle cx="25.9" cy="42" r="1.35" opacity="0.49" />
|
|
23
|
+
<circle cx="28.2" cy="38.1" r="1.45" opacity="0.56" />
|
|
24
|
+
<circle cx="30.9" cy="29.3" r="1.55" opacity="0.64" />
|
|
25
|
+
<circle cx="33" cy="25.4" r="1.65" opacity="0.72" />
|
|
26
|
+
<circle cx="36" cy="22.1" r="1.75" opacity="0.8" />
|
|
27
|
+
<circle cx="38" cy="20.4" r="1.85" opacity="0.88" />
|
|
28
|
+
</g>
|
|
29
|
+
<circle cx="13" cy="50" r="4.5" fill="#9fc67b" opacity="0.16" />
|
|
30
|
+
<circle cx="13" cy="50" r="2.4" fill="#9fc67b" />
|
|
31
|
+
<circle cx="29.5" cy="34" r="5" fill="#b6daf1" opacity="0.16" />
|
|
32
|
+
<circle cx="29.5" cy="34" r="2.7" fill="#b6daf1" />
|
|
33
|
+
<circle cx="44" cy="17.5" r="9.5" fill="url(#logo-halo)" />
|
|
34
|
+
<circle cx="44" cy="17.5" r="3.4" fill="#f1aa57" />
|
|
35
|
+
<circle cx="44" cy="17.5" r="1.7" fill="#ffe0b0" />
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|