@brainpilot/web 0.0.3 → 0.0.5
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/dist/assets/index-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +187 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +184 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +84 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/* --------------------------------------------------------------------------
|
|
2
|
+
* TimelineTab — message chronology as horizontal swimlanes (one row per
|
|
3
|
+
* agent). Hand-rolled SVG. Supports horizontal zoom (wheel), pan (drag),
|
|
4
|
+
* Fit All, per-agent + per-type filtering, and PNG export (html-to-image).
|
|
5
|
+
*
|
|
6
|
+
* Always global: shows every inter-agent message in the session.
|
|
7
|
+
* ------------------------------------------------------------------------ */
|
|
8
|
+
import { useMemo, useRef, useState } from "react";
|
|
9
|
+
import { Download, Filter, Maximize2, Inbox } from "lucide-react";
|
|
10
|
+
import { toPng } from "html-to-image";
|
|
11
|
+
import { ChatMessage } from "../../contracts/backend";
|
|
12
|
+
import { useT } from "../../i18n/useT";
|
|
13
|
+
import { getMessageEdge, msgTypeKind } from "./agentNetworkShared";
|
|
14
|
+
|
|
15
|
+
interface TimelineTabProps {
|
|
16
|
+
messages: ChatMessage[];
|
|
17
|
+
now: number;
|
|
18
|
+
/** Click a dot → caller selects that agent (and flips to Detail tab). */
|
|
19
|
+
onSelectMessage: (agentName: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TimelineDot {
|
|
23
|
+
id: string;
|
|
24
|
+
agent: string; // swimlane owner = sender
|
|
25
|
+
to: string;
|
|
26
|
+
ts: number;
|
|
27
|
+
kind: "delegate" | "result" | "neutral";
|
|
28
|
+
content: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROW_H = 36;
|
|
32
|
+
const LABEL_W = 88;
|
|
33
|
+
const PAD_TOP = 28;
|
|
34
|
+
const TICK_COUNT = 6;
|
|
35
|
+
|
|
36
|
+
export function TimelineTab({ messages, now, onSelectMessage }: TimelineTabProps) {
|
|
37
|
+
const t = useT();
|
|
38
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
const [zoom, setZoom] = useState(1);
|
|
40
|
+
const [panX, setPanX] = useState(0);
|
|
41
|
+
const [hideDelegate, setHideDelegate] = useState(false);
|
|
42
|
+
const [hideResult, setHideResult] = useState(false);
|
|
43
|
+
const [hideOther, setHideOther] = useState(false);
|
|
44
|
+
const [hovered, setHovered] = useState<TimelineDot | null>(null);
|
|
45
|
+
const dragRef = useRef<{ startX: number; startPan: number } | null>(null);
|
|
46
|
+
|
|
47
|
+
const dots = useMemo<TimelineDot[]>(() => {
|
|
48
|
+
const out: TimelineDot[] = [];
|
|
49
|
+
for (const m of messages) {
|
|
50
|
+
const e = getMessageEdge(m);
|
|
51
|
+
if (!e) continue;
|
|
52
|
+
const ts = new Date(e.timestamp).getTime();
|
|
53
|
+
if (!Number.isFinite(ts)) continue;
|
|
54
|
+
out.push({
|
|
55
|
+
id: e.id,
|
|
56
|
+
agent: e.from,
|
|
57
|
+
to: e.to,
|
|
58
|
+
ts,
|
|
59
|
+
kind: msgTypeKind(e.msgType),
|
|
60
|
+
content: e.content,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out.sort((a, b) => a.ts - b.ts);
|
|
64
|
+
}, [messages]);
|
|
65
|
+
|
|
66
|
+
// Swimlane order: by first activity; "principal" pinned first if present.
|
|
67
|
+
const lanes = useMemo(() => {
|
|
68
|
+
const firstSeen = new Map<string, number>();
|
|
69
|
+
for (const d of dots) {
|
|
70
|
+
if (!firstSeen.has(d.agent)) firstSeen.set(d.agent, d.ts);
|
|
71
|
+
}
|
|
72
|
+
const names = Array.from(firstSeen.keys());
|
|
73
|
+
names.sort((a, b) => {
|
|
74
|
+
if (a === "principal") return -1;
|
|
75
|
+
if (b === "principal") return 1;
|
|
76
|
+
return (firstSeen.get(a) ?? 0) - (firstSeen.get(b) ?? 0);
|
|
77
|
+
});
|
|
78
|
+
return names;
|
|
79
|
+
}, [dots]);
|
|
80
|
+
|
|
81
|
+
const timeBounds = useMemo(() => {
|
|
82
|
+
if (dots.length === 0) return { start: now - 60_000, end: now };
|
|
83
|
+
const start = dots[0].ts;
|
|
84
|
+
const end = Math.max(now, dots[dots.length - 1].ts);
|
|
85
|
+
return { start, end: end === start ? start + 60_000 : end };
|
|
86
|
+
}, [dots, now]);
|
|
87
|
+
|
|
88
|
+
if (dots.length === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="agent-timeline__empty">
|
|
91
|
+
<Inbox size={20} />
|
|
92
|
+
<p>{t("timeline.empty")}</p>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const laneAreaW = 760; // base virtual width (before zoom)
|
|
98
|
+
const plotW = laneAreaW * zoom;
|
|
99
|
+
const svgW = LABEL_W + plotW;
|
|
100
|
+
const svgH = PAD_TOP + lanes.length * ROW_H + 8;
|
|
101
|
+
const span = timeBounds.end - timeBounds.start;
|
|
102
|
+
|
|
103
|
+
const xOf = (ts: number) => LABEL_W + panX + ((ts - timeBounds.start) / span) * plotW;
|
|
104
|
+
const laneIndex = (name: string) => lanes.indexOf(name);
|
|
105
|
+
const yOf = (name: string) => PAD_TOP + laneIndex(name) * ROW_H + ROW_H / 2;
|
|
106
|
+
|
|
107
|
+
const isHidden = (kind: TimelineDot["kind"]) =>
|
|
108
|
+
(kind === "delegate" && hideDelegate) ||
|
|
109
|
+
(kind === "result" && hideResult) ||
|
|
110
|
+
(kind === "neutral" && hideOther);
|
|
111
|
+
|
|
112
|
+
// Tick labels: adaptive granularity.
|
|
113
|
+
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => {
|
|
114
|
+
const ts = timeBounds.start + (span * i) / TICK_COUNT;
|
|
115
|
+
return { ts, x: xOf(ts) };
|
|
116
|
+
});
|
|
117
|
+
const formatTick = (ts: number) => {
|
|
118
|
+
const d = new Date(ts);
|
|
119
|
+
if (span < 60_000) return `${d.getSeconds()}s`;
|
|
120
|
+
if (span < 3_600_000) return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
121
|
+
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// delegate→result arcs (pair each delegate with the next result the target sends).
|
|
125
|
+
const arcs: { x1: number; y1: number; x2: number; y2: number; id: string }[] = [];
|
|
126
|
+
const usedResult = new Set<string>();
|
|
127
|
+
for (const d of dots) {
|
|
128
|
+
if (d.kind !== "delegate") continue;
|
|
129
|
+
const match = dots.find(
|
|
130
|
+
(r) => r.kind === "result" && r.agent === d.to && r.ts > d.ts && !usedResult.has(r.id),
|
|
131
|
+
);
|
|
132
|
+
if (match) {
|
|
133
|
+
usedResult.add(match.id);
|
|
134
|
+
arcs.push({
|
|
135
|
+
id: `${d.id}->${match.id}`,
|
|
136
|
+
x1: xOf(d.ts),
|
|
137
|
+
y1: yOf(d.agent),
|
|
138
|
+
x2: xOf(match.ts),
|
|
139
|
+
y2: yOf(match.agent),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const handleWheel = (e: React.WheelEvent) => {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
147
|
+
setZoom((z) => Math.min(8, Math.max(1, z * factor)));
|
|
148
|
+
};
|
|
149
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
150
|
+
dragRef.current = { startX: e.clientX, startPan: panX };
|
|
151
|
+
};
|
|
152
|
+
const handleMouseMove = (e: React.MouseEvent) => {
|
|
153
|
+
if (!dragRef.current) return;
|
|
154
|
+
setPanX(dragRef.current.startPan + (e.clientX - dragRef.current.startX));
|
|
155
|
+
};
|
|
156
|
+
const endDrag = () => {
|
|
157
|
+
dragRef.current = null;
|
|
158
|
+
};
|
|
159
|
+
const fitAll = () => {
|
|
160
|
+
setZoom(1);
|
|
161
|
+
setPanX(0);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const exportPng = async () => {
|
|
165
|
+
if (!containerRef.current) return;
|
|
166
|
+
try {
|
|
167
|
+
const dataUrl = await toPng(containerRef.current, {
|
|
168
|
+
backgroundColor:
|
|
169
|
+
getComputedStyle(document.documentElement)
|
|
170
|
+
.getPropertyValue("--color-surface")
|
|
171
|
+
.trim() || "#ffffff",
|
|
172
|
+
pixelRatio: 2,
|
|
173
|
+
});
|
|
174
|
+
const link = document.createElement("a");
|
|
175
|
+
link.download = `agent-timeline-${Date.now()}.png`;
|
|
176
|
+
link.href = dataUrl;
|
|
177
|
+
link.click();
|
|
178
|
+
} catch {
|
|
179
|
+
/* export is best-effort; ignore failures */
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="agent-timeline">
|
|
185
|
+
<div className="agent-timeline__controls">
|
|
186
|
+
<button type="button" className="agent-timeline__btn" onClick={fitAll}>
|
|
187
|
+
<Maximize2 size={12} /> {t("timeline.fit")}
|
|
188
|
+
</button>
|
|
189
|
+
<span className="agent-timeline__filters">
|
|
190
|
+
<Filter size={12} />
|
|
191
|
+
<FilterToggle label={t("timeline.filter.delegate")} active={!hideDelegate} onClick={() => setHideDelegate((v) => !v)} dotClass="delegate" />
|
|
192
|
+
<FilterToggle label={t("timeline.filter.result")} active={!hideResult} onClick={() => setHideResult((v) => !v)} dotClass="result" />
|
|
193
|
+
<FilterToggle label={t("timeline.filter.other")} active={!hideOther} onClick={() => setHideOther((v) => !v)} dotClass="neutral" />
|
|
194
|
+
</span>
|
|
195
|
+
<button type="button" className="agent-timeline__btn" onClick={exportPng}>
|
|
196
|
+
<Download size={12} /> PNG
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div
|
|
201
|
+
className="agent-timeline__scroll"
|
|
202
|
+
ref={containerRef}
|
|
203
|
+
onWheel={handleWheel}
|
|
204
|
+
onMouseDown={handleMouseDown}
|
|
205
|
+
onMouseMove={handleMouseMove}
|
|
206
|
+
onMouseUp={endDrag}
|
|
207
|
+
onMouseLeave={() => {
|
|
208
|
+
endDrag();
|
|
209
|
+
setHovered(null);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<svg
|
|
213
|
+
className="agent-timeline__svg"
|
|
214
|
+
width={svgW}
|
|
215
|
+
height={svgH}
|
|
216
|
+
viewBox={`0 0 ${svgW} ${svgH}`}
|
|
217
|
+
role="img"
|
|
218
|
+
aria-label={t("timeline.aria")}
|
|
219
|
+
>
|
|
220
|
+
{/* zebra lanes + labels */}
|
|
221
|
+
{lanes.map((name, i) => (
|
|
222
|
+
<g key={name}>
|
|
223
|
+
<rect
|
|
224
|
+
x={0}
|
|
225
|
+
y={PAD_TOP + i * ROW_H}
|
|
226
|
+
width={svgW}
|
|
227
|
+
height={ROW_H}
|
|
228
|
+
className={i % 2 === 0 ? "agent-timeline__lane agent-timeline__lane--even" : "agent-timeline__lane agent-timeline__lane--odd"}
|
|
229
|
+
/>
|
|
230
|
+
<text x={6} y={PAD_TOP + i * ROW_H + ROW_H / 2 + 4} className="agent-timeline__lane-label">
|
|
231
|
+
{name.length > 11 ? `${name.slice(0, 10)}…` : name}
|
|
232
|
+
</text>
|
|
233
|
+
</g>
|
|
234
|
+
))}
|
|
235
|
+
|
|
236
|
+
{/* time ticks */}
|
|
237
|
+
{ticks.map((t, i) => (
|
|
238
|
+
<g key={i}>
|
|
239
|
+
<line x1={t.x} x2={t.x} y1={PAD_TOP - 6} y2={svgH - 4} className="agent-timeline__tick" />
|
|
240
|
+
<text x={t.x} y={14} textAnchor="middle" className="agent-timeline__tick-label">
|
|
241
|
+
{formatTick(t.ts)}
|
|
242
|
+
</text>
|
|
243
|
+
</g>
|
|
244
|
+
))}
|
|
245
|
+
|
|
246
|
+
{/* "now" marker */}
|
|
247
|
+
<line x1={xOf(now)} x2={xOf(now)} y1={PAD_TOP - 6} y2={svgH - 4} className="agent-timeline__now" />
|
|
248
|
+
|
|
249
|
+
{/* delegate→result arcs */}
|
|
250
|
+
{arcs.map((a) => {
|
|
251
|
+
const midY = Math.min(a.y1, a.y2) - 10;
|
|
252
|
+
return (
|
|
253
|
+
<path
|
|
254
|
+
key={a.id}
|
|
255
|
+
className="agent-timeline__arc"
|
|
256
|
+
d={`M ${a.x1} ${a.y1} Q ${(a.x1 + a.x2) / 2} ${midY} ${a.x2} ${a.y2}`}
|
|
257
|
+
fill="none"
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
|
|
262
|
+
{/* dots */}
|
|
263
|
+
{dots.map((d) => {
|
|
264
|
+
if (isHidden(d.kind)) return null;
|
|
265
|
+
return (
|
|
266
|
+
<circle
|
|
267
|
+
key={d.id}
|
|
268
|
+
className={`agent-timeline__dot agent-timeline__dot--${d.kind}`}
|
|
269
|
+
cx={xOf(d.ts)}
|
|
270
|
+
cy={yOf(d.agent)}
|
|
271
|
+
r={5}
|
|
272
|
+
onClick={() => onSelectMessage(d.agent)}
|
|
273
|
+
onMouseEnter={() => setHovered(d)}
|
|
274
|
+
onMouseLeave={() => setHovered((cur) => (cur?.id === d.id ? null : cur))}
|
|
275
|
+
>
|
|
276
|
+
<title>{`${d.agent} → ${d.to}\n${d.content.slice(0, 100)}`}</title>
|
|
277
|
+
</circle>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
280
|
+
</svg>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{hovered ? (
|
|
284
|
+
<div className="agent-timeline__hint-card">
|
|
285
|
+
<strong>
|
|
286
|
+
{hovered.agent} → {hovered.to}
|
|
287
|
+
</strong>
|
|
288
|
+
<span className={`agent-timeline__hint-type agent-timeline__hint-type--${hovered.kind}`}>
|
|
289
|
+
{hovered.kind}
|
|
290
|
+
</span>
|
|
291
|
+
<p>{hovered.content ? `${hovered.content.slice(0, 120)}${hovered.content.length > 120 ? "…" : ""}` : "(empty)"}</p>
|
|
292
|
+
</div>
|
|
293
|
+
) : null}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function FilterToggle({
|
|
299
|
+
label,
|
|
300
|
+
active,
|
|
301
|
+
onClick,
|
|
302
|
+
dotClass,
|
|
303
|
+
}: {
|
|
304
|
+
label: string;
|
|
305
|
+
active: boolean;
|
|
306
|
+
onClick: () => void;
|
|
307
|
+
dotClass: string;
|
|
308
|
+
}) {
|
|
309
|
+
return (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
className={`agent-timeline__filter ${active ? "is-active" : ""}`}
|
|
313
|
+
aria-pressed={active}
|
|
314
|
+
onClick={onClick}
|
|
315
|
+
>
|
|
316
|
+
<i className={`agent-timeline__filter-dot agent-timeline__dot--${dotClass}`} />
|
|
317
|
+
{label}
|
|
318
|
+
</button>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { Minus, Plus, RotateCcw } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { TraceNode } from "../../contracts/backend";
|
|
4
|
+
import {
|
|
5
|
+
TraceLayoutDirection,
|
|
6
|
+
buildTraceLayout,
|
|
7
|
+
getNodeKind,
|
|
8
|
+
normalizeStatus,
|
|
9
|
+
relationLabels,
|
|
10
|
+
truncateNodeTitle,
|
|
11
|
+
} from "./traceLayout";
|
|
12
|
+
|
|
13
|
+
interface TraceGraphViewProps {
|
|
14
|
+
/** Nodes already filtered / sliced / edge-pruned by the host. */
|
|
15
|
+
nodes: TraceNode[];
|
|
16
|
+
direction: TraceLayoutDirection;
|
|
17
|
+
selectedNodeId: string | null;
|
|
18
|
+
onSelectNode: (id: string) => void;
|
|
19
|
+
zoom: number;
|
|
20
|
+
onZoomChange: (zoom: number) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Bumping this re-fits and recenters the viewport on the current content.
|
|
23
|
+
* The host bumps it when the graph advances (e.g. playback auto-advance) so
|
|
24
|
+
* newly revealed nodes scroll into view; manual scrubbing leaves it alone.
|
|
25
|
+
*/
|
|
26
|
+
fitToken?: number | string;
|
|
27
|
+
/** Shown when there are no nodes to display. */
|
|
28
|
+
emptyLabel?: string;
|
|
29
|
+
zoomLabels?: {
|
|
30
|
+
controls?: string;
|
|
31
|
+
zoomIn?: string;
|
|
32
|
+
zoomOut?: string;
|
|
33
|
+
reset?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Presentational reasoning-trace graph: the SVG DAG, zoom controls, viewport
|
|
39
|
+
* pan/drag, wheel-zoom, and per-node drag offsets. Owns only view-local state
|
|
40
|
+
* (node offsets, drag). Extracted from TracePanel so the live trace view and
|
|
41
|
+
* the demo replay render the same graph.
|
|
42
|
+
*/
|
|
43
|
+
export function TraceGraphView({
|
|
44
|
+
nodes,
|
|
45
|
+
direction,
|
|
46
|
+
selectedNodeId,
|
|
47
|
+
onSelectNode,
|
|
48
|
+
zoom,
|
|
49
|
+
onZoomChange,
|
|
50
|
+
fitToken,
|
|
51
|
+
emptyLabel,
|
|
52
|
+
zoomLabels,
|
|
53
|
+
}: TraceGraphViewProps) {
|
|
54
|
+
const [nodeOffsets, setNodeOffsets] = useState<Map<string, { dx: number; dy: number }>>(new Map());
|
|
55
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
56
|
+
const layoutRef = useRef<ReturnType<typeof buildTraceLayout> | null>(null);
|
|
57
|
+
const draggingNodeRef = useRef<{
|
|
58
|
+
nodeId: string;
|
|
59
|
+
startClientX: number;
|
|
60
|
+
startClientY: number;
|
|
61
|
+
startDx: number;
|
|
62
|
+
startDy: number;
|
|
63
|
+
} | null>(null);
|
|
64
|
+
const dragRef = useRef<{
|
|
65
|
+
isDragging: boolean;
|
|
66
|
+
startX: number;
|
|
67
|
+
startY: number;
|
|
68
|
+
scrollLeft: number;
|
|
69
|
+
scrollTop: number;
|
|
70
|
+
hasPanned: boolean;
|
|
71
|
+
}>({ isDragging: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0, hasPanned: false });
|
|
72
|
+
|
|
73
|
+
const layout = useMemo(() => buildTraceLayout(nodes, direction), [direction, nodes]);
|
|
74
|
+
const adjustedLayout = useMemo(() => {
|
|
75
|
+
if (nodeOffsets.size === 0) return layout;
|
|
76
|
+
const adjustedPositioned = layout.positioned.map((p) => {
|
|
77
|
+
const offset = nodeOffsets.get(p.node.id);
|
|
78
|
+
if (!offset) return p;
|
|
79
|
+
return { ...p, x: p.x + offset.dx, y: p.y + offset.dy };
|
|
80
|
+
});
|
|
81
|
+
const adjustedById = new Map(adjustedPositioned.map((p) => [p.node.id, p]));
|
|
82
|
+
return { ...layout, positioned: adjustedPositioned, byId: adjustedById };
|
|
83
|
+
}, [layout, nodeOffsets]);
|
|
84
|
+
layoutRef.current = adjustedLayout;
|
|
85
|
+
const zoomPercent = Math.round(zoom * 100);
|
|
86
|
+
|
|
87
|
+
const zoomIn = () => onZoomChange(Math.min(2, Number((zoom + 0.1).toFixed(2))));
|
|
88
|
+
const zoomOut = () => onZoomChange(Math.max(0.05, Number((zoom - 0.1).toFixed(2))));
|
|
89
|
+
const resetZoom = () => onZoomChange(1);
|
|
90
|
+
|
|
91
|
+
// Re-fit + recenter on fitToken change (host signals an advance).
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (fitToken === undefined || !viewportRef.current) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const l = layoutRef.current;
|
|
97
|
+
if (!l || l.positioned.length === 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const viewport = viewportRef.current;
|
|
101
|
+
const padding = 80;
|
|
102
|
+
const xs = l.positioned.map((p) => p.x);
|
|
103
|
+
const ys = l.positioned.map((p) => p.y);
|
|
104
|
+
const minX = Math.min(...xs) - padding;
|
|
105
|
+
const minY = Math.min(...ys) - padding;
|
|
106
|
+
const maxX = Math.max(...xs.map((x) => x + l.nodeWidth)) + padding;
|
|
107
|
+
const maxY = Math.max(...ys.map((y) => y + l.nodeHeight)) + padding;
|
|
108
|
+
const contentWidth = maxX - minX;
|
|
109
|
+
const contentHeight = maxY - minY;
|
|
110
|
+
const viewportWidth = viewport.clientWidth;
|
|
111
|
+
const viewportHeight = viewport.clientHeight;
|
|
112
|
+
const fitZoom = Math.min(viewportWidth / contentWidth, viewportHeight / contentHeight, 1.5);
|
|
113
|
+
const nextZoom = Math.max(0.05, Number(fitZoom.toFixed(2)));
|
|
114
|
+
// Avoid micro-rescales on every reveal — only re-zoom on a meaningful change,
|
|
115
|
+
// so the graph doesn't visibly "breathe" as nodes stream in.
|
|
116
|
+
if (Math.abs(nextZoom - zoom) > 0.06) {
|
|
117
|
+
onZoomChange(nextZoom);
|
|
118
|
+
}
|
|
119
|
+
const effectiveZoom = Math.abs(nextZoom - zoom) > 0.06 ? nextZoom : zoom;
|
|
120
|
+
requestAnimationFrame(() => {
|
|
121
|
+
if (!viewportRef.current) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const vp = viewportRef.current;
|
|
125
|
+
const centerScrollLeft = ((minX + maxX) / 2) * effectiveZoom - viewportWidth / 2;
|
|
126
|
+
const centerScrollTop = ((minY + maxY) / 2) * effectiveZoom - viewportHeight / 2;
|
|
127
|
+
vp.scrollTo({
|
|
128
|
+
left: Math.max(0, centerScrollLeft),
|
|
129
|
+
top: Math.max(0, centerScrollTop),
|
|
130
|
+
behavior: "smooth",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [fitToken]);
|
|
135
|
+
|
|
136
|
+
// Per-node drag.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
139
|
+
if (!draggingNodeRef.current) return;
|
|
140
|
+
const { nodeId, startClientX, startClientY, startDx, startDy } = draggingNodeRef.current;
|
|
141
|
+
const dx = startDx + (e.clientX - startClientX) / zoom;
|
|
142
|
+
const dy = startDy + (e.clientY - startClientY) / zoom;
|
|
143
|
+
setNodeOffsets((prev) => {
|
|
144
|
+
const next = new Map(prev);
|
|
145
|
+
next.set(nodeId, { dx, dy });
|
|
146
|
+
return next;
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const handleMouseUp = () => {
|
|
150
|
+
draggingNodeRef.current = null;
|
|
151
|
+
};
|
|
152
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
153
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
154
|
+
return () => {
|
|
155
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
156
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
157
|
+
};
|
|
158
|
+
}, [zoom]);
|
|
159
|
+
|
|
160
|
+
if (nodes.length === 0) {
|
|
161
|
+
return <p className="trace-empty">{emptyLabel ?? ""}</p>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div className="trace-zoom-controls" aria-label={zoomLabels?.controls}>
|
|
167
|
+
<button disabled={zoom <= 0.05} onClick={zoomOut} type="button" aria-label={zoomLabels?.zoomOut}>
|
|
168
|
+
<Minus size={13} />
|
|
169
|
+
</button>
|
|
170
|
+
<span>{zoomPercent}%</span>
|
|
171
|
+
<button disabled={zoom >= 2} onClick={zoomIn} type="button" aria-label={zoomLabels?.zoomIn}>
|
|
172
|
+
<Plus size={13} />
|
|
173
|
+
</button>
|
|
174
|
+
<button disabled={zoom === 1} onClick={resetZoom} type="button" aria-label={zoomLabels?.reset}>
|
|
175
|
+
<RotateCcw size={11} />
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
<div
|
|
179
|
+
ref={viewportRef}
|
|
180
|
+
className="trace-map__viewport"
|
|
181
|
+
onMouseDown={(event) => {
|
|
182
|
+
const viewport = event.currentTarget;
|
|
183
|
+
dragRef.current = {
|
|
184
|
+
isDragging: true,
|
|
185
|
+
startX: event.clientX,
|
|
186
|
+
startY: event.clientY,
|
|
187
|
+
scrollLeft: viewport.scrollLeft,
|
|
188
|
+
scrollTop: viewport.scrollTop,
|
|
189
|
+
hasPanned: false,
|
|
190
|
+
};
|
|
191
|
+
}}
|
|
192
|
+
onMouseMove={(event) => {
|
|
193
|
+
if (!dragRef.current.isDragging) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const dx = event.clientX - dragRef.current.startX;
|
|
197
|
+
const dy = event.clientY - dragRef.current.startY;
|
|
198
|
+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
|
199
|
+
dragRef.current.hasPanned = true;
|
|
200
|
+
}
|
|
201
|
+
const viewport = event.currentTarget;
|
|
202
|
+
viewport.scrollLeft = dragRef.current.scrollLeft - dx;
|
|
203
|
+
viewport.scrollTop = dragRef.current.scrollTop - dy;
|
|
204
|
+
}}
|
|
205
|
+
onMouseUp={() => {
|
|
206
|
+
dragRef.current.isDragging = false;
|
|
207
|
+
}}
|
|
208
|
+
onMouseLeave={() => {
|
|
209
|
+
dragRef.current.isDragging = false;
|
|
210
|
+
}}
|
|
211
|
+
onWheel={(event) => {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
if (event.deltaY === 0) return;
|
|
214
|
+
const step = event.deltaY > 0 ? -0.1 : 0.1;
|
|
215
|
+
onZoomChange(Math.max(0.05, Math.min(2, Number((zoom + step).toFixed(2)))));
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<svg
|
|
219
|
+
height={adjustedLayout.height * zoom}
|
|
220
|
+
viewBox={`0 0 ${adjustedLayout.width} ${adjustedLayout.height}`}
|
|
221
|
+
width={adjustedLayout.width * zoom}
|
|
222
|
+
role="img"
|
|
223
|
+
>
|
|
224
|
+
<defs>
|
|
225
|
+
<marker id="trace-arrow" markerHeight="8" markerWidth="8" orient="auto" refX="7" refY="4">
|
|
226
|
+
<path d="M 0 0 L 8 4 L 0 8 z" />
|
|
227
|
+
</marker>
|
|
228
|
+
</defs>
|
|
229
|
+
{adjustedLayout.positioned.flatMap(({ node }) =>
|
|
230
|
+
node.parents.map((parentRef) => {
|
|
231
|
+
const parent = adjustedLayout.byId.get(parentRef.id);
|
|
232
|
+
const child = adjustedLayout.byId.get(node.id);
|
|
233
|
+
if (!parent || !child) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const isHorizontal = direction === "LR";
|
|
237
|
+
const startX = parent.x + (isHorizontal ? adjustedLayout.nodeWidth : adjustedLayout.nodeWidth / 2);
|
|
238
|
+
const startY = parent.y + (isHorizontal ? adjustedLayout.nodeHeight / 2 : adjustedLayout.nodeHeight);
|
|
239
|
+
const endX = child.x + (isHorizontal ? 0 : adjustedLayout.nodeWidth / 2);
|
|
240
|
+
const endY = child.y + (isHorizontal ? adjustedLayout.nodeHeight / 2 : 0);
|
|
241
|
+
const path = isHorizontal
|
|
242
|
+
? `M ${startX} ${startY} C ${startX + 54} ${startY}, ${endX - 54} ${endY}, ${endX} ${endY}`
|
|
243
|
+
: `M ${startX} ${startY} C ${startX} ${startY + 42}, ${endX} ${endY - 42}, ${endX} ${endY}`;
|
|
244
|
+
const midX = (startX + endX) / 2;
|
|
245
|
+
const midY = (startY + endY) / 2;
|
|
246
|
+
const labelText = parentRef.relation ? (relationLabels[parentRef.relation] || parentRef.relation) : "";
|
|
247
|
+
const labelWidth = Math.max(48, labelText.length * 5.5 + 10);
|
|
248
|
+
return (
|
|
249
|
+
<g className={`trace-edge trace-edge--${parentRef.relation || "necessitated_by"}`} key={`${parentRef.id}-${node.id}`}>
|
|
250
|
+
<path d={path} />
|
|
251
|
+
{parentRef.relation ? (
|
|
252
|
+
<g className="trace-edge-label">
|
|
253
|
+
<rect
|
|
254
|
+
x={midX - labelWidth / 2}
|
|
255
|
+
y={midY - 14}
|
|
256
|
+
width={labelWidth}
|
|
257
|
+
height={13}
|
|
258
|
+
rx={4}
|
|
259
|
+
/>
|
|
260
|
+
<text x={midX} y={midY - 7}>{labelText}</text>
|
|
261
|
+
</g>
|
|
262
|
+
) : null}
|
|
263
|
+
</g>
|
|
264
|
+
);
|
|
265
|
+
}),
|
|
266
|
+
)}
|
|
267
|
+
{adjustedLayout.positioned.map(({ node, x, y }) => (
|
|
268
|
+
<g
|
|
269
|
+
className={`trace-map-node trace-map-node--${getNodeKind(node)} trace-map-node--${normalizeStatus(node.status)} ${selectedNodeId === node.id ? "is-selected" : ""} ${draggingNodeRef.current?.nodeId === node.id ? "is-dragging" : ""}`}
|
|
270
|
+
key={node.id}
|
|
271
|
+
onMouseDown={(e) => {
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
const offset = nodeOffsets.get(node.id) || { dx: 0, dy: 0 };
|
|
274
|
+
draggingNodeRef.current = {
|
|
275
|
+
nodeId: node.id,
|
|
276
|
+
startClientX: e.clientX,
|
|
277
|
+
startClientY: e.clientY,
|
|
278
|
+
startDx: offset.dx,
|
|
279
|
+
startDy: offset.dy,
|
|
280
|
+
};
|
|
281
|
+
}}
|
|
282
|
+
onClick={() => {
|
|
283
|
+
if (dragRef.current.hasPanned || draggingNodeRef.current) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
onSelectNode(node.id);
|
|
287
|
+
}}
|
|
288
|
+
style={{ transform: `translate(${x}px, ${y}px)` }}
|
|
289
|
+
>
|
|
290
|
+
<rect height={adjustedLayout.nodeHeight} rx="8" width={adjustedLayout.nodeWidth} />
|
|
291
|
+
<circle className={`trace-node__dot--${normalizeStatus(node.status)}`} cx="16" cy="24" r="4" />
|
|
292
|
+
<text className="trace-map-node__title" x="28" y="26">{truncateNodeTitle(node.title)}</text>
|
|
293
|
+
<text className="trace-map-node__meta" x="28" y="44">{node.agent || getNodeKind(node)}</text>
|
|
294
|
+
<text className="trace-map-node__kind" x="28" y="58">{getNodeKind(node)}</text>
|
|
295
|
+
</g>
|
|
296
|
+
))}
|
|
297
|
+
</svg>
|
|
298
|
+
</div>
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
}
|