@schlessera/brain-ui-react 0.18.0 → 0.20.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.
Files changed (86) hide show
  1. package/dist/components/activity/activity-page.d.ts +11 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -0
  3. package/dist/components/activity/activity-page.js +243 -0
  4. package/dist/components/activity/activity-page.js.map +1 -0
  5. package/dist/components/activity/digest-card.d.ts +10 -0
  6. package/dist/components/activity/digest-card.d.ts.map +1 -0
  7. package/dist/components/activity/digest-card.js +55 -0
  8. package/dist/components/activity/digest-card.js.map +1 -0
  9. package/dist/components/activity/push-toggle.d.ts +2 -0
  10. package/dist/components/activity/push-toggle.d.ts.map +1 -0
  11. package/dist/components/activity/push-toggle.js +121 -0
  12. package/dist/components/activity/push-toggle.js.map +1 -0
  13. package/dist/components/activity/span-bits.d.ts +76 -0
  14. package/dist/components/activity/span-bits.d.ts.map +1 -0
  15. package/dist/components/activity/span-bits.js +127 -0
  16. package/dist/components/activity/span-bits.js.map +1 -0
  17. package/dist/components/chat/chat-page.d.ts.map +1 -1
  18. package/dist/components/chat/chat-page.js +4 -1
  19. package/dist/components/chat/chat-page.js.map +1 -1
  20. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  21. package/dist/components/chat/session-drawer.js +1 -15
  22. package/dist/components/chat/session-drawer.js.map +1 -1
  23. package/dist/components/chat/subagent-view.d.ts +18 -0
  24. package/dist/components/chat/subagent-view.d.ts.map +1 -0
  25. package/dist/components/chat/subagent-view.js +83 -0
  26. package/dist/components/chat/subagent-view.js.map +1 -0
  27. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  28. package/dist/components/chat/tool-call-timeline.js +40 -4
  29. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  30. package/dist/components/chat/tool-views.d.ts +4 -0
  31. package/dist/components/chat/tool-views.d.ts.map +1 -1
  32. package/dist/components/chat/tool-views.js +26 -0
  33. package/dist/components/chat/tool-views.js.map +1 -1
  34. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  35. package/dist/components/layout/mobile-tab-bar.js +10 -4
  36. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  37. package/dist/components/layout/side-rail.d.ts.map +1 -1
  38. package/dist/components/layout/side-rail.js +7 -4
  39. package/dist/components/layout/side-rail.js.map +1 -1
  40. package/dist/components/settings/models-tab.d.ts +15 -0
  41. package/dist/components/settings/models-tab.d.ts.map +1 -1
  42. package/dist/components/settings/models-tab.js +100 -21
  43. package/dist/components/settings/models-tab.js.map +1 -1
  44. package/dist/hooks/use-now.d.ts +8 -0
  45. package/dist/hooks/use-now.d.ts.map +1 -0
  46. package/dist/hooks/use-now.js +32 -0
  47. package/dist/hooks/use-now.js.map +1 -0
  48. package/dist/hooks/use-websocket.d.ts.map +1 -1
  49. package/dist/hooks/use-websocket.js +46 -0
  50. package/dist/hooks/use-websocket.js.map +1 -1
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/api-client.d.ts +67 -1
  56. package/dist/lib/api-client.d.ts.map +1 -1
  57. package/dist/lib/api-client.js +41 -0
  58. package/dist/lib/api-client.js.map +1 -1
  59. package/dist/stores/activity-store.d.ts +108 -0
  60. package/dist/stores/activity-store.d.ts.map +1 -0
  61. package/dist/stores/activity-store.js +359 -0
  62. package/dist/stores/activity-store.js.map +1 -0
  63. package/dist/stores/ui-store.d.ts +9 -1
  64. package/dist/stores/ui-store.d.ts.map +1 -1
  65. package/dist/stores/ui-store.js +3 -0
  66. package/dist/stores/ui-store.js.map +1 -1
  67. package/dist/styles.css +1 -1
  68. package/package.json +2 -2
  69. package/src/components/activity/activity-page.tsx +623 -0
  70. package/src/components/activity/digest-card.tsx +105 -0
  71. package/src/components/activity/push-toggle.tsx +157 -0
  72. package/src/components/activity/span-bits.tsx +194 -0
  73. package/src/components/chat/chat-page.tsx +17 -0
  74. package/src/components/chat/session-drawer.tsx +1 -12
  75. package/src/components/chat/subagent-view.tsx +239 -0
  76. package/src/components/chat/tool-call-timeline.tsx +61 -3
  77. package/src/components/chat/tool-views.tsx +22 -0
  78. package/src/components/layout/mobile-tab-bar.tsx +25 -5
  79. package/src/components/layout/side-rail.tsx +17 -0
  80. package/src/components/settings/models-tab.tsx +135 -21
  81. package/src/hooks/use-now.ts +32 -0
  82. package/src/hooks/use-websocket.ts +44 -0
  83. package/src/index.ts +1 -0
  84. package/src/lib/api-client.ts +111 -0
  85. package/src/stores/activity-store.ts +451 -0
  86. package/src/stores/ui-store.ts +13 -1
@@ -0,0 +1,623 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import {
3
+ Activity as ActivityIcon,
4
+ AlertTriangle,
5
+ ArrowLeft,
6
+ Bot,
7
+ ChevronRight,
8
+ Clock,
9
+ RefreshCw,
10
+ Timer,
11
+ } from "lucide-react";
12
+ import { useShallow } from "zustand/react/shallow";
13
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
14
+ import type { ActivitySpan } from "@schlessera/brain-ui-sdk/protocol";
15
+
16
+ import {
17
+ api,
18
+ type ActivityIntent,
19
+ type ActivityRollups,
20
+ type ActivityRunRollup,
21
+ type ActivityRunSummary,
22
+ } from "../../lib/api-client.js";
23
+ import { sendClientMessage } from "../../hooks/use-websocket.js";
24
+ import { useNow } from "../../hooks/use-now.js";
25
+ import { useActivityStore, runSpans } from "../../stores/activity-store.js";
26
+ import { useChatStore } from "../../stores/chat-store.js";
27
+ import { useUIStore } from "../../stores/ui-store.js";
28
+ import { cn } from "../../lib/utils.js";
29
+ import {
30
+ formatDuration,
31
+ formatRelativeTime,
32
+ formatTokenCount,
33
+ } from "../chat/tool-views.js";
34
+ import {
35
+ SpanPayload,
36
+ SpanStatusDot,
37
+ formatAggregateCost,
38
+ formatEffectiveCost,
39
+ runCostText,
40
+ spanToolLabel,
41
+ } from "./span-bits.js";
42
+ import { PushToggle } from "./push-toggle.js";
43
+ import { SettingsPanel } from "../settings/settings-panel.js";
44
+
45
+ /**
46
+ * The Activity surface: an INDEX of all agent activity — live runs first,
47
+ * then history — with cost/token rollups. Deliberately not a residence:
48
+ * a session run deep-links back into its chat, a subagent into the drill-in
49
+ * stack; only a cron run (which has no chat around it) details here.
50
+ *
51
+ * Live rows ride the index-view activity subscription while this surface is
52
+ * open; history and rollups come from the REST activity API.
53
+ */
54
+ export function ActivityPage() {
55
+ const supported = useActivityStore((s) => s.supported);
56
+ const connectionEpoch = useActivityStore((s) => s.connectionEpoch);
57
+ const liveSpans = useActivityStore((s) => s.spans);
58
+ const setActiveView = useUIStore((s) => s.setActiveView);
59
+ const openSettings = useUIStore((s) => s.openSettings);
60
+ const settingsPanelOpen = useUIStore((s) => s.settingsPanelOpen);
61
+ const setSettingsPanelOpen = useUIStore((s) => s.setSettingsPanelOpen);
62
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
63
+ const [runs, setRuns] = useState<{ live: ActivityRunSummary[]; history: ActivityRunSummary[] } | null>(null);
64
+ const [rollups, setRollups] = useState<ActivityRollups | null>(null);
65
+ const [error, setError] = useState<string | null>(null);
66
+ const [detailRunId, setDetailRunId] = useState<string | null>(null);
67
+ const [pricingStale, setPricingStale] = useState(false);
68
+
69
+ const inbox = useActivityStore((s) => s.inbox);
70
+ const loadInbox = useActivityStore((s) => s.loadInbox);
71
+ const acknowledgeIntent = useActivityStore((s) => s.acknowledgeIntent);
72
+ const acknowledgeAllIntents = useActivityStore((s) => s.acknowledgeAllIntents);
73
+
74
+ const refresh = () => {
75
+ api
76
+ .activityRuns({ limit: 100 })
77
+ .then(setRuns)
78
+ .catch((err) => setError(err instanceof Error ? err.message : String(err)));
79
+ api.activityRollups(7).then(setRollups).catch(() => {});
80
+ // Surface the pricing warning only when the table is stale AND refreshes
81
+ // are failing — a merely-aging table heals itself. A rejection (including
82
+ // 404 from a server that predates the route) means no signal: stay quiet.
83
+ api
84
+ .pricingState()
85
+ .then((s) => setPricingStale(Boolean(s.stale && s.error)))
86
+ .catch(() => setPricingStale(false));
87
+ void loadInbox();
88
+ };
89
+
90
+ // Re-runs on every reconnect (connectionEpoch): the new socket has no
91
+ // server-side subscriptions, and the REST refresh heals whatever finished
92
+ // while disconnected.
93
+ useEffect(() => {
94
+ refresh();
95
+ if (supported) {
96
+ sendClientMessage({ type: "activity_subscribe", view: "index" });
97
+ return () => {
98
+ sendClientMessage({ type: "activity_unsubscribe", view: "index" });
99
+ };
100
+ }
101
+ return undefined;
102
+ }, [supported, connectionEpoch]);
103
+
104
+ // Deep-link consumer: `#/activity/<runId>` (the push notification landing
105
+ // spot — the shell routes it here but leaves the hash intact) opens that
106
+ // run's detail. Opening/closing detail writes the hash back via
107
+ // history.replaceState so the link stays shareable without history spam.
108
+ useEffect(() => {
109
+ const applyHash = () => {
110
+ const match = /^#\/activity\/(.+)$/.exec(window.location.hash);
111
+ if (match) setDetailRunId(decodeURIComponent(match[1]!));
112
+ };
113
+ applyHash();
114
+ window.addEventListener("hashchange", applyHash);
115
+ return () => window.removeEventListener("hashchange", applyHash);
116
+ }, []);
117
+
118
+ function showDetail(runId: string | null) {
119
+ setDetailRunId(runId);
120
+ const target = runId ? `#/activity/${encodeURIComponent(runId)}` : "#/activity";
121
+ if (window.location.hash !== target) {
122
+ // window-qualified: `history` is the run list in this scope.
123
+ window.history.replaceState(null, "", target);
124
+ }
125
+ }
126
+
127
+ // Live open roots from the stream override/extend the REST snapshot.
128
+ const liveRoots = useMemo(() => {
129
+ const roots: ActivitySpan[] = [];
130
+ for (const byId of Object.values(liveSpans)) {
131
+ for (const span of Object.values(byId)) {
132
+ if (!span.parentSpanId && span.outcome === undefined) roots.push(span);
133
+ }
134
+ }
135
+ return roots.sort((a, b) => b.startedAt - a.startedAt);
136
+ }, [liveSpans]);
137
+
138
+ const liveRunIds = new Set(liveRoots.map((r) => r.runId));
139
+ const restLive = (runs?.live ?? []).filter((r) => !liveRunIds.has(r.runId));
140
+ const history = (runs?.history ?? []).filter((r) => !liveRunIds.has(r.runId));
141
+
142
+ function openRun(row: {
143
+ runId: string;
144
+ origin: string;
145
+ sessionId?: string | null;
146
+ running?: boolean;
147
+ }) {
148
+ if (row.origin === "session" && row.sessionId) {
149
+ // Chat is the residence — a session run opens its session.
150
+ setActiveSession(row.sessionId);
151
+ setActiveView("chat");
152
+ return;
153
+ }
154
+ showDetail(row.runId);
155
+ }
156
+
157
+ /** An inbox tap routes like any run row: chat for session runs, detail otherwise. */
158
+ function openIntent(intent: ActivityIntent) {
159
+ const liveRoot = liveRoots.find((r) => r.runId === intent.runId);
160
+ if (liveRoot) {
161
+ openRun({ runId: liveRoot.runId, origin: liveRoot.origin, sessionId: liveRoot.sessionId });
162
+ return;
163
+ }
164
+ const known = [...(runs?.live ?? []), ...(runs?.history ?? [])].find(
165
+ (r) => r.runId === intent.runId
166
+ );
167
+ if (known) {
168
+ openRun(known);
169
+ } else {
170
+ showDetail(intent.runId);
171
+ }
172
+ }
173
+
174
+ if (detailRunId) {
175
+ return <RunDetail runId={detailRunId} onBack={() => showDetail(null)} />;
176
+ }
177
+
178
+ return (
179
+ <div className="flex h-full flex-col overflow-y-auto">
180
+ {/* Hosted here like GraphPage does: the chat page (the usual host) is
181
+ hidden while this view is active, so the staleness deep-link below
182
+ needs its own panel mount. */}
183
+ <SettingsPanel
184
+ open={settingsPanelOpen}
185
+ onClose={() => setSettingsPanelOpen(false)}
186
+ />
187
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
188
+ <ActivityIcon className="h-4 w-4 text-muted-foreground" />
189
+ <h1 className="text-sm font-medium">Activity</h1>
190
+ <div className="ml-auto flex items-center gap-2">
191
+ {pricingStale && (
192
+ <button
193
+ type="button"
194
+ onClick={() => openSettings("models")}
195
+ className="flex items-center gap-1 rounded-md p-1.5 text-amber-500 transition-colors hover:bg-surface-raised hover:text-amber-400"
196
+ aria-label="Pricing refresh is failing — costs may use stale rates. Open Settings"
197
+ title="Pricing refresh is failing — costs may use stale rates. Open Settings"
198
+ >
199
+ <AlertTriangle className="h-3.5 w-3.5" />
200
+ <span className="hidden text-[10px] sm:inline">Pricing stale</span>
201
+ </button>
202
+ )}
203
+ <PushToggle />
204
+ </div>
205
+ <button
206
+ type="button"
207
+ onClick={refresh}
208
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
209
+ aria-label="Refresh"
210
+ >
211
+ <RefreshCw className="h-3.5 w-3.5" />
212
+ </button>
213
+ </div>
214
+
215
+ <div className="mx-auto w-full max-w-3xl space-y-6 p-4">
216
+ {error && (
217
+ <p className="text-xs text-destructive">Could not load activity: {error}</p>
218
+ )}
219
+
220
+ {inbox.length > 0 && (
221
+ <section>
222
+ <div className="mb-2 flex items-center">
223
+ <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
224
+ Needs attention
225
+ </h2>
226
+ <button
227
+ type="button"
228
+ onClick={() => void acknowledgeAllIntents()}
229
+ className="ml-auto text-[11px] text-muted-foreground transition-colors hover:text-foreground"
230
+ >
231
+ Dismiss all
232
+ </button>
233
+ </div>
234
+ <div className="space-y-1">
235
+ {inbox.map((intent) => (
236
+ <div
237
+ key={intent.id}
238
+ className={cn(
239
+ "flex items-center gap-2 rounded-lg border px-3 py-2 text-xs",
240
+ intent.kind === "failure"
241
+ ? "border-destructive/30 bg-destructive/5"
242
+ : "border-border-subtle bg-surface"
243
+ )}
244
+ >
245
+ {intent.kind === "failure" ? (
246
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
247
+ ) : intent.kind === "stuck" ? (
248
+ <Timer className="h-3.5 w-3.5 shrink-0 text-amber-500" />
249
+ ) : (
250
+ <ActivityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
251
+ )}
252
+ <button
253
+ type="button"
254
+ className="min-w-0 flex-1 truncate text-left hover:underline"
255
+ onClick={() => {
256
+ void acknowledgeIntent(intent.id);
257
+ openIntent(intent);
258
+ }}
259
+ >
260
+ {intent.title}
261
+ </button>
262
+ <span className="shrink-0 text-[10px] text-muted-foreground/60">
263
+ {formatRelativeTime(intent.createdAt)}
264
+ </span>
265
+ <button
266
+ type="button"
267
+ onClick={() => void acknowledgeIntent(intent.id)}
268
+ className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
269
+ aria-label="Dismiss"
270
+ >
271
+ ×
272
+ </button>
273
+ </div>
274
+ ))}
275
+ </div>
276
+ </section>
277
+ )}
278
+
279
+ {rollups && <RollupCards rollups={rollups} />}
280
+
281
+ <section>
282
+ <h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
283
+ Running now
284
+ </h2>
285
+ {liveRoots.length === 0 && restLive.length === 0 ? (
286
+ <p className="text-xs text-muted-foreground/70">Nothing is running.</p>
287
+ ) : (
288
+ <div className="space-y-1">
289
+ {liveRoots.map((span) => (
290
+ <LiveRow key={span.runId} span={span} onOpen={openRun} />
291
+ ))}
292
+ {restLive.map((run) => (
293
+ <RunRow key={run.runId} run={run} onOpen={openRun} />
294
+ ))}
295
+ </div>
296
+ )}
297
+ </section>
298
+
299
+ <section>
300
+ <h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
301
+ History
302
+ </h2>
303
+ {history.length === 0 ? (
304
+ <p className="text-xs text-muted-foreground/70">No recorded runs yet.</p>
305
+ ) : (
306
+ <div className="space-y-1">
307
+ {history.map((run) => (
308
+ <RunRow key={run.runId} run={run} onOpen={openRun} />
309
+ ))}
310
+ </div>
311
+ )}
312
+ </section>
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ /** Today's key in the server's configured zone (mirrors its day formatter). */
319
+ function todayKey(timeZone: string): string {
320
+ let fmt: Intl.DateTimeFormat;
321
+ try {
322
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone, dateStyle: "short" });
323
+ } catch {
324
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", dateStyle: "short" });
325
+ }
326
+ return fmt.format(new Date());
327
+ }
328
+
329
+ function RollupCards({ rollups }: { rollups: ActivityRollups }) {
330
+ // days[0] is merely the newest day WITH runs — on a quiet day that is
331
+ // yesterday, so look today up by its actual key (0 when absent).
332
+ const today = rollups.days.find((d) => d.day === todayKey(rollups.timeZone));
333
+ const week = rollups.days.reduce(
334
+ (acc, d) => ({
335
+ runs: acc.runs + d.runs,
336
+ failures: acc.failures + d.failures,
337
+ costUsd: acc.costUsd + d.costUsd,
338
+ effectiveCostUsd: acc.effectiveCostUsd + (d.effectiveCostUsd ?? 0),
339
+ unpricedRuns: acc.unpricedRuns + (d.unpricedRuns ?? 0),
340
+ tokens: acc.tokens + d.inputTokens + d.outputTokens,
341
+ }),
342
+ { runs: 0, failures: 0, costUsd: 0, effectiveCostUsd: 0, unpricedRuns: 0, tokens: 0 }
343
+ );
344
+ // A server that predates pricing omits the effective fields entirely; the
345
+ // card then keeps its single list-price number instead of claiming an
346
+ // effective $0.00 it never computed.
347
+ const hasEffective = rollups.days.some((d) => d.effectiveCostUsd !== undefined);
348
+ return (
349
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
350
+ <StatCard label="Runs today" value={String(today?.runs ?? 0)} />
351
+ <StatCard
352
+ label="Failures (7d)"
353
+ value={String(week.failures)}
354
+ alert={week.failures > 0}
355
+ />
356
+ <StatCard
357
+ label="Spend (7d)"
358
+ value={
359
+ hasEffective
360
+ ? formatAggregateCost(week.effectiveCostUsd, week.unpricedRuns)
361
+ : `$${week.costUsd.toFixed(2)}`
362
+ }
363
+ secondary={
364
+ hasEffective && (
365
+ <>
366
+ list ${week.costUsd.toFixed(2)}
367
+ {week.unpricedRuns > 0 && (
368
+ <>
369
+ {/* Two cards per row below `sm`: the qualifier compacts. */}
370
+ <span className="hidden sm:inline"> · {week.unpricedRuns} unpriced</span>
371
+ <span className="sm:hidden"> · {week.unpricedRuns}?</span>
372
+ </>
373
+ )}
374
+ </>
375
+ )
376
+ }
377
+ />
378
+ <StatCard label="Tokens (7d)" value={formatTokenCount(week.tokens)} />
379
+ </div>
380
+ );
381
+ }
382
+
383
+ function StatCard({
384
+ label,
385
+ value,
386
+ secondary,
387
+ alert,
388
+ }: {
389
+ label: string;
390
+ value: string;
391
+ /** Muted small line between the value and the label (e.g. the list-cost qualifier). */
392
+ secondary?: ReactNode;
393
+ alert?: boolean;
394
+ }) {
395
+ return (
396
+ <div className="rounded-lg border border-border-subtle bg-surface p-3">
397
+ <div className={cn("text-lg font-semibold", alert && "text-destructive")}>{value}</div>
398
+ {secondary && (
399
+ <div className="truncate text-[10px] text-muted-foreground/70">{secondary}</div>
400
+ )}
401
+ <div className="text-[11px] text-muted-foreground">{label}</div>
402
+ </div>
403
+ );
404
+ }
405
+
406
+ function LiveRow({
407
+ span,
408
+ onOpen,
409
+ }: {
410
+ span: ActivitySpan;
411
+ onOpen: (row: { runId: string; origin: string; sessionId?: string | null }) => void;
412
+ }) {
413
+ const children = useActivityStore(
414
+ useShallow((s) => Object.values(s.spans[span.runId] ?? {}).filter((x) => x.parentSpanId))
415
+ );
416
+ const now = useNow();
417
+ const current = children.filter((c) => c.outcome === undefined).at(-1);
418
+ const currentLabel = current ? spanToolLabel(current) : null;
419
+ return (
420
+ <button
421
+ type="button"
422
+ onClick={() => onOpen({ runId: span.runId, origin: span.origin, sessionId: span.sessionId })}
423
+ className="flex w-full items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised"
424
+ >
425
+ <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-primary" />
426
+ {span.origin === "cron" ? (
427
+ <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
428
+ ) : (
429
+ <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
430
+ )}
431
+ <span className="truncate font-medium">
432
+ {span.jobName ?? span.name}
433
+ </span>
434
+ {/* A span-only run (plain cron job) has no current step — name+elapsed
435
+ is its whole live story. */}
436
+ {currentLabel && (
437
+ <span className="truncate text-muted-foreground">· {currentLabel}</span>
438
+ )}
439
+ <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
440
+ {formatDuration(now - span.startedAt)}
441
+ </span>
442
+ </button>
443
+ );
444
+ }
445
+
446
+ function RunRow({
447
+ run,
448
+ onOpen,
449
+ }: {
450
+ run: ActivityRunSummary;
451
+ onOpen: (row: ActivityRunSummary) => void;
452
+ }) {
453
+ const failed = isFailureOutcome(run.outcome);
454
+ // Effective cost only — list price lives on the Spend card and the detail
455
+ // view. "—" is unknown, never $0.00 (AE3); a pre-pricing server that never
456
+ // sent the field keeps the original list-cost span instead (see runCostText).
457
+ const cost = runCostText(run);
458
+ return (
459
+ <button
460
+ type="button"
461
+ onClick={() => onOpen(run)}
462
+ className={cn(
463
+ "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised",
464
+ failed && "border border-destructive/30 bg-destructive/5"
465
+ )}
466
+ >
467
+ {run.origin === "cron" ? (
468
+ <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
469
+ ) : (
470
+ <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
471
+ )}
472
+ <span className="truncate">{run.jobName ?? run.name}</span>
473
+ {failed && <AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />}
474
+ {run.outcome && run.outcome !== "success" && (
475
+ <span className={cn("text-[10px] uppercase", failed ? "text-destructive" : "text-muted-foreground")}>
476
+ {run.outcome}
477
+ </span>
478
+ )}
479
+ <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
480
+ {cost !== null && <span>{cost}</span>}
481
+ {run.durationMs !== null && (
482
+ <span className="flex items-center gap-0.5">
483
+ <Timer className="h-3 w-3" />
484
+ {formatDuration(run.durationMs)}
485
+ </span>
486
+ )}
487
+ <span>{formatRelativeTime(run.startedAt)}</span>
488
+ </span>
489
+ </button>
490
+ );
491
+ }
492
+
493
+ /** Chat-less run detail (cron runs; pruned runs show their rollup). */
494
+ function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
495
+ const streamed = useActivityStore(useShallow((s) => runSpans(s, runId)));
496
+ const applySnapshot = useActivityStore((s) => s.applySnapshot);
497
+ const [pruned, setPruned] = useState<object | null>(null);
498
+ const [rollup, setRollup] = useState<ActivityRunRollup | null>(null);
499
+ const [missing, setMissing] = useState(false);
500
+
501
+ useEffect(() => {
502
+ api
503
+ // Payload bodies are excluded from run detail by default; this view's
504
+ // rows expand to them, so opt in.
505
+ .activityRun(runId, { includePayloads: true })
506
+ .then((detail) => {
507
+ if (detail.rollup) setRollup(detail.rollup);
508
+ if (detail.detailPruned) {
509
+ setPruned(detail.rollup ?? {});
510
+ } else if (detail.spans) {
511
+ applySnapshot({
512
+ type: "activity_snapshot",
513
+ view: "run",
514
+ runId,
515
+ spans: detail.spans,
516
+ events: detail.events ?? [],
517
+ highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
518
+ });
519
+ }
520
+ })
521
+ .catch(() => setMissing(true));
522
+ }, [runId, applySnapshot]);
523
+
524
+ return (
525
+ <div className="flex h-full flex-col overflow-y-auto">
526
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
527
+ <button
528
+ type="button"
529
+ onClick={onBack}
530
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
531
+ aria-label="Back"
532
+ >
533
+ <ArrowLeft className="h-4 w-4" />
534
+ </button>
535
+ <h1 className="truncate text-sm font-medium">{runId}</h1>
536
+ </div>
537
+ <div className="mx-auto w-full max-w-3xl space-y-1 p-4">
538
+ {missing && <p className="text-xs text-muted-foreground">Unknown run.</p>}
539
+ {rollup && (
540
+ <div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-[11px] text-muted-foreground">
541
+ <span>
542
+ list{" "}
543
+ <span className="font-[family-name:var(--font-mono)] text-foreground/80">
544
+ {formatEffectiveCost(rollup.costUsd)}
545
+ </span>
546
+ </span>
547
+ <span>
548
+ effective{" "}
549
+ <span className="font-[family-name:var(--font-mono)] text-foreground/80">
550
+ {formatEffectiveCost(rollup.effectiveCostUsd, rollup.pricingEstimate)}
551
+ </span>
552
+ </span>
553
+ {rollup.billingMode && (
554
+ <span>{rollup.billingMode === "api" ? "API billed" : "subscription billed"}</span>
555
+ )}
556
+ {rollup.pricingEstimate && <span>~ estimated rates</span>}
557
+ </div>
558
+ )}
559
+ {pruned && (
560
+ <div className="rounded-lg border border-border-subtle bg-surface p-3 text-xs text-muted-foreground">
561
+ Detail pruned — only the rollup remains.
562
+ <pre className="mt-2 overflow-x-auto text-[11px]">{JSON.stringify(pruned, null, 2)}</pre>
563
+ </div>
564
+ )}
565
+ {streamed.map((span) => (
566
+ <DetailSpanRow key={span.spanId} span={span} depth={depthOf(span, streamed)} />
567
+ ))}
568
+ </div>
569
+ </div>
570
+ );
571
+ }
572
+
573
+ /** One span tree row; tool spans expand to their recorded payload (AE7). */
574
+ function DetailSpanRow({ span, depth }: { span: ActivitySpan; depth: number }) {
575
+ const [expanded, setExpanded] = useState(false);
576
+ const expandable = span.kind === "tool";
577
+ return (
578
+ <div style={{ paddingLeft: `${depth * 16}px` }}>
579
+ <div
580
+ className={cn(
581
+ "flex items-center gap-2 text-xs text-muted-foreground",
582
+ expandable && "cursor-pointer hover:text-foreground"
583
+ )}
584
+ onClick={expandable ? () => setExpanded((v) => !v) : undefined}
585
+ >
586
+ <SpanStatusDot span={span} />
587
+ <span className="truncate font-[family-name:var(--font-mono)]">
588
+ {spanToolLabel(span)}
589
+ </span>
590
+ {span.outcome && span.outcome !== "success" && (
591
+ <span className="text-[10px] uppercase">{span.outcome}</span>
592
+ )}
593
+ {span.outcomeReason && (
594
+ <span className="truncate text-[10px] text-muted-foreground/60">
595
+ {span.outcomeReason}
596
+ </span>
597
+ )}
598
+ <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
599
+ {span.endedAt !== undefined
600
+ ? formatDuration(span.endedAt - span.startedAt)
601
+ : "…"}
602
+ </span>
603
+ {expandable && (
604
+ <ChevronRight
605
+ className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
606
+ />
607
+ )}
608
+ </div>
609
+ {expanded && <SpanPayload spanId={span.spanId} />}
610
+ </div>
611
+ );
612
+ }
613
+
614
+ function depthOf(span: ActivitySpan, all: ActivitySpan[]): number {
615
+ let depth = 0;
616
+ let current: ActivitySpan | undefined = span;
617
+ while (current?.parentSpanId) {
618
+ depth += 1;
619
+ current = all.find((s) => s.spanId === current!.parentSpanId);
620
+ if (depth > 6) break;
621
+ }
622
+ return depth;
623
+ }