@brainpilot/web 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,127 @@
1
+ /* --------------------------------------------------------------------------
2
+ * NodeTooltip — a small hover card shown beside an agent node in the network
3
+ * graph. Rendered as an absolutely-positioned <div> INSIDE the viewport
4
+ * container (NOT inside the SVG) so it isn't clipped by foreignObject and can
5
+ * use normal DOM layout. The parent computes the anchor rect (node bounding
6
+ * box, relative to the viewport container) and passes it in; the tooltip flips
7
+ * to the left side when there isn't room on the right.
8
+ * ------------------------------------------------------------------------ */
9
+ import { Inbox, Send } from "lucide-react";
10
+ import {
11
+ getAgentIcon,
12
+ getAgentProfile,
13
+ relativeTime,
14
+ statusKind,
15
+ } from "./agentNetworkShared";
16
+
17
+ export interface NodeTooltipData {
18
+ name: string;
19
+ isLive: boolean;
20
+ status: string;
21
+ task: string;
22
+ updatedAt?: string;
23
+ sent: number;
24
+ received: number;
25
+ }
26
+
27
+ interface NodeTooltipProps {
28
+ data: NodeTooltipData;
29
+ now: number;
30
+ /** Node bounding box, in pixels relative to the viewport container. */
31
+ anchor: { left: number; top: number; width: number; height: number };
32
+ /** Viewport container size, for collision detection. */
33
+ container: { width: number; height: number };
34
+ }
35
+
36
+ const TOOLTIP_WIDTH = 240;
37
+ const GAP = 12;
38
+
39
+ function truncate(text: string, max: number): string {
40
+ if (text.length <= max) return text;
41
+ return `${text.slice(0, max - 1)}…`;
42
+ }
43
+
44
+ export function NodeTooltip({ data, now, anchor, container }: NodeTooltipProps) {
45
+ const Icon = getAgentIcon(data.name);
46
+ const profile = getAgentProfile(data.name);
47
+ const kind = data.isLive ? statusKind(data.status) : "dormant";
48
+
49
+ // Decide side: prefer right of the node; flip left if it would overflow.
50
+ const wouldOverflowRight = anchor.left + anchor.width + GAP + TOOLTIP_WIDTH > container.width;
51
+ const side: "left" | "right" = wouldOverflowRight ? "left" : "right";
52
+
53
+ const left =
54
+ side === "right"
55
+ ? anchor.left + anchor.width + GAP
56
+ : Math.max(GAP, anchor.left - GAP - TOOLTIP_WIDTH);
57
+
58
+ // Vertically center on the node, clamped to the container.
59
+ const rawTop = anchor.top + anchor.height / 2;
60
+ const top = Math.min(Math.max(GAP, rawTop), container.height - GAP);
61
+
62
+ return (
63
+ <div
64
+ className={`agent-network__tooltip agent-network__tooltip--${side}`}
65
+ role="tooltip"
66
+ style={{ left, top, width: TOOLTIP_WIDTH }}
67
+ >
68
+ <div className="agent-network__tooltip-head">
69
+ <span
70
+ className="agent-network__tooltip-avatar"
71
+ style={{ ["--agent-accent" as string]: accentVar(profile.accent) }}
72
+ >
73
+ <Icon size={14} />
74
+ </span>
75
+ <div className="agent-network__tooltip-id">
76
+ <strong>{data.name}</strong>
77
+ <span>{profile.role}</span>
78
+ </div>
79
+ </div>
80
+
81
+ <div className="agent-network__tooltip-row">
82
+ <span className={`agent-network__tooltip-status agent-network__tooltip-status--${kind}`}>
83
+ <i className={`agent-network__tooltip-dot agent-network__tooltip-dot--${kind}`} />
84
+ {data.isLive ? data.status : "dormant"}
85
+ </span>
86
+ </div>
87
+
88
+ {data.isLive ? (
89
+ <p className="agent-network__tooltip-task">
90
+ {data.task ? truncate(data.task, 60) : "Idle — waiting for instructions"}
91
+ </p>
92
+ ) : (
93
+ <p className="agent-network__tooltip-task agent-network__tooltip-task--dormant">
94
+ Not yet spawned by Principal
95
+ </p>
96
+ )}
97
+
98
+ {data.isLive && data.updatedAt ? (
99
+ <p className="agent-network__tooltip-meta">Updated {relativeTime(data.updatedAt, now)}</p>
100
+ ) : null}
101
+
102
+ <div className="agent-network__tooltip-counts">
103
+ <span>
104
+ <Send size={11} /> Sent {data.sent}
105
+ </span>
106
+ <span>
107
+ <Inbox size={11} /> Received {data.received}
108
+ </span>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function accentVar(accent: string): string {
115
+ switch (accent) {
116
+ case "info":
117
+ return "var(--color-info)";
118
+ case "success":
119
+ return "var(--color-success)";
120
+ case "warning":
121
+ return "var(--color-warning)";
122
+ case "danger":
123
+ return "var(--color-danger)";
124
+ default:
125
+ return "var(--color-text-subtle)";
126
+ }
127
+ }
@@ -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
+ }