@schlessera/brain-ui-react 0.17.0 → 0.19.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 (81) 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 +208 -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 +51 -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 +82 -0
  12. package/dist/components/activity/push-toggle.js.map +1 -0
  13. package/dist/components/activity/span-bits.d.ts +28 -0
  14. package/dist/components/activity/span-bits.d.ts.map +1 -0
  15. package/dist/components/activity/span-bits.js +43 -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 +79 -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/hooks/use-now.d.ts +8 -0
  41. package/dist/hooks/use-now.d.ts.map +1 -0
  42. package/dist/hooks/use-now.js +32 -0
  43. package/dist/hooks/use-now.js.map +1 -0
  44. package/dist/hooks/use-websocket.d.ts.map +1 -1
  45. package/dist/hooks/use-websocket.js +46 -0
  46. package/dist/hooks/use-websocket.js.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/lib/api-client.d.ts +40 -1
  52. package/dist/lib/api-client.d.ts.map +1 -1
  53. package/dist/lib/api-client.js +28 -0
  54. package/dist/lib/api-client.js.map +1 -1
  55. package/dist/stores/activity-store.d.ts +93 -0
  56. package/dist/stores/activity-store.d.ts.map +1 -0
  57. package/dist/stores/activity-store.js +310 -0
  58. package/dist/stores/activity-store.js.map +1 -0
  59. package/dist/stores/ui-store.d.ts +9 -1
  60. package/dist/stores/ui-store.d.ts.map +1 -1
  61. package/dist/stores/ui-store.js +3 -0
  62. package/dist/stores/ui-store.js.map +1 -1
  63. package/dist/styles.css +1 -1
  64. package/package.json +2 -2
  65. package/src/components/activity/activity-page.tsx +495 -0
  66. package/src/components/activity/digest-card.tsx +101 -0
  67. package/src/components/activity/push-toggle.tsx +128 -0
  68. package/src/components/activity/span-bits.tsx +71 -0
  69. package/src/components/chat/chat-page.tsx +17 -0
  70. package/src/components/chat/session-drawer.tsx +1 -12
  71. package/src/components/chat/subagent-view.tsx +229 -0
  72. package/src/components/chat/tool-call-timeline.tsx +61 -3
  73. package/src/components/chat/tool-views.tsx +22 -0
  74. package/src/components/layout/mobile-tab-bar.tsx +25 -5
  75. package/src/components/layout/side-rail.tsx +17 -0
  76. package/src/hooks/use-now.ts +32 -0
  77. package/src/hooks/use-websocket.ts +44 -0
  78. package/src/index.ts +1 -0
  79. package/src/lib/api-client.ts +75 -0
  80. package/src/stores/activity-store.ts +402 -0
  81. package/src/stores/ui-store.ts +13 -1
@@ -0,0 +1,128 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Bell, BellOff } from "lucide-react";
3
+
4
+ import { api } from "../../lib/api-client.js";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ /**
8
+ * The push enable/disable control, living on the Activity surface (where the
9
+ * alerts it governs land). The permission model is THREE-state, not a bare
10
+ * toggle: once the browser prompt is denied, Chrome silently ignores every
11
+ * further in-page request — so the blocked state must say where the real
12
+ * switch lives (site settings) instead of offering a button that does
13
+ * nothing. Where the platform has no push at all (iOS Safari in-browser,
14
+ * pre-16.4), the control renders disabled-with-explanation, not hidden.
15
+ */
16
+ type PushState =
17
+ | "unsupported"
18
+ | "not-asked"
19
+ | "blocked"
20
+ | "subscribed"
21
+ | "unsubscribed";
22
+
23
+ export function PushToggle() {
24
+ const [state, setState] = useState<PushState>("unsupported");
25
+ const [busy, setBusy] = useState(false);
26
+
27
+ useEffect(() => {
28
+ void (async () => {
29
+ if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
30
+ setState("unsupported");
31
+ return;
32
+ }
33
+ if (Notification.permission === "denied") {
34
+ setState("blocked");
35
+ return;
36
+ }
37
+ if (Notification.permission === "default") {
38
+ setState("not-asked");
39
+ return;
40
+ }
41
+ const registration = await navigator.serviceWorker.ready;
42
+ const subscription = await registration.pushManager.getSubscription();
43
+ setState(subscription ? "subscribed" : "unsubscribed");
44
+ })();
45
+ }, []);
46
+
47
+ async function enable() {
48
+ setBusy(true);
49
+ try {
50
+ const permission = await Notification.requestPermission();
51
+ if (permission === "denied") {
52
+ setState("blocked");
53
+ return;
54
+ }
55
+ if (permission !== "granted") {
56
+ setState("not-asked");
57
+ return;
58
+ }
59
+ const { publicKey } = await api.pushPublicKey();
60
+ const registration = await navigator.serviceWorker.ready;
61
+ const subscription = await registration.pushManager.subscribe({
62
+ userVisibleOnly: true,
63
+ applicationServerKey: publicKey,
64
+ });
65
+ await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
66
+ setState("subscribed");
67
+ } catch {
68
+ setState("unsubscribed");
69
+ } finally {
70
+ setBusy(false);
71
+ }
72
+ }
73
+
74
+ async function disable() {
75
+ setBusy(true);
76
+ try {
77
+ const registration = await navigator.serviceWorker.ready;
78
+ const subscription = await registration.pushManager.getSubscription();
79
+ if (subscription) {
80
+ await api.pushUnsubscribe(subscription.endpoint).catch(() => {});
81
+ await subscription.unsubscribe();
82
+ }
83
+ setState("unsubscribed");
84
+ } finally {
85
+ setBusy(false);
86
+ }
87
+ }
88
+
89
+ if (state === "unsupported") {
90
+ return (
91
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60" title="This browser has no web push. On iOS, install the app to the home screen (iOS 16.4+).">
92
+ <BellOff className="h-3.5 w-3.5" />
93
+ No push here
94
+ </div>
95
+ );
96
+ }
97
+
98
+ if (state === "blocked") {
99
+ return (
100
+ <div
101
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
102
+ title="Notifications are blocked at the browser level. Allow them in this site's browser settings, then reload."
103
+ >
104
+ <BellOff className="h-3.5 w-3.5 text-destructive" />
105
+ Blocked in browser settings
106
+ </div>
107
+ );
108
+ }
109
+
110
+ const on = state === "subscribed";
111
+ return (
112
+ <button
113
+ type="button"
114
+ disabled={busy}
115
+ onClick={() => void (on ? disable() : enable())}
116
+ className={cn(
117
+ "flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-50",
118
+ on
119
+ ? "text-primary hover:bg-surface-raised"
120
+ : "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
121
+ )}
122
+ title={on ? "Push notifications are on for this device" : "Get a push notification when background work fails"}
123
+ >
124
+ {on ? <Bell className="h-3.5 w-3.5" /> : <BellOff className="h-3.5 w-3.5" />}
125
+ {on ? "Push on" : "Enable push"}
126
+ </button>
127
+ );
128
+ }
@@ -0,0 +1,71 @@
1
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
2
+ import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
3
+
4
+ import { cn } from "../../lib/utils.js";
5
+ import { getToolLabel } from "../chat/tool-views.js";
6
+
7
+ /**
8
+ * THE status dot for activity spans — one outcome→color mapping so every
9
+ * surface (timeline rows, drill-in, activity index) agrees on what a color
10
+ * means: running pulses primary, success is accent, failures (per
11
+ * `isFailureOutcome`) and denied are destructive, cancelled is muted.
12
+ */
13
+ export function SpanStatusDot({
14
+ span,
15
+ outcome,
16
+ running,
17
+ className,
18
+ }: {
19
+ span?: ActivitySpan;
20
+ /** Alternative to `span` when only the outcome is at hand. */
21
+ outcome?: ActivitySpanOutcome | string | null;
22
+ running?: boolean;
23
+ /** Size/position overrides; defaults to h-1.5 w-1.5. */
24
+ className?: string;
25
+ }) {
26
+ const resolved = span ? span.outcome : outcome;
27
+ const isRunning = running ?? (span !== undefined && span.outcome === undefined);
28
+ return (
29
+ <span
30
+ className={cn(
31
+ "h-1.5 w-1.5 shrink-0 rounded-full",
32
+ isRunning
33
+ ? "animate-pulse bg-primary"
34
+ : resolved === "success"
35
+ ? "bg-accent"
36
+ : isFailureOutcome(resolved) || resolved === "denied"
37
+ ? "bg-destructive"
38
+ : "bg-muted-foreground",
39
+ className
40
+ )}
41
+ />
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Display label for a span. Prefers the server-lifted `toolName`; root spans
47
+ * label by kind (a turn or cron root is not a tool call); the last resort
48
+ * un-parses the `execute_tool` naming convention for spans recorded before
49
+ * the field existed.
50
+ */
51
+ export function spanToolLabel(span: ActivitySpan): string {
52
+ if (span.toolName) return getToolLabel(span.toolName);
53
+ if (span.kind === "turn") return "Turn";
54
+ if (span.kind === "cron") return span.jobName ?? span.name;
55
+ return getToolLabel(span.name.replace(/^execute_tool /, ""));
56
+ }
57
+
58
+ /** The "9+" unread-count bubble shared by the rail and the tab bar. Hidden at 0. */
59
+ export function CountBadge({ count, className }: { count: number; className?: string }) {
60
+ if (count <= 0) return null;
61
+ return (
62
+ <span
63
+ className={cn(
64
+ "absolute flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-white",
65
+ className
66
+ )}
67
+ >
68
+ {count > 9 ? "9+" : count}
69
+ </span>
70
+ );
71
+ }
@@ -33,6 +33,8 @@ import { WelcomeState } from "./welcome-state.js";
33
33
  import { ShareIntake } from "./share-card.js";
34
34
  import { CommandPalette } from "./command-palette.js";
35
35
  import { SessionDrawer } from "./session-drawer.js";
36
+ import { SubagentView } from "./subagent-view.js";
37
+ import { DigestCard } from "../activity/digest-card.js";
36
38
  import { SettingsPanel } from "../settings/settings-panel.js";
37
39
  import { StreamingPanel } from "../quick-actions/streaming-modal.js";
38
40
  import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
@@ -125,6 +127,7 @@ export function ChatPage() {
125
127
 
126
128
  const sessionPanelOpen = useUIStore((s) => s.sessionPanelOpen);
127
129
  const setSessionPanelOpen = useUIStore((s) => s.setSessionPanelOpen);
130
+ const subagentStack = useUIStore((s) => s.subagentStack);
128
131
  const syncPanelOpen = useUIStore((s) => s.syncPanelOpen);
129
132
  const setSyncPanelOpen = useUIStore((s) => s.setSyncPanelOpen);
130
133
  const whatsupPanelOpen = useUIStore((s) => s.whatsupPanelOpen);
@@ -521,6 +524,17 @@ export function ChatPage() {
521
524
  }
522
525
  />
523
526
 
527
+ {/* Subagent drill-in: an overlay stack — chat is the residence, the
528
+ drill-in opens over it and backs out level by level. */}
529
+ {subagentStack.length > 0 && (
530
+ <div className="fixed inset-0 z-40 bg-background">
531
+ <SubagentView
532
+ spanId={subagentStack[subagentStack.length - 1]!}
533
+ onApproval={handleToolApproval}
534
+ />
535
+ </div>
536
+ )}
537
+
524
538
  {/* Panels */}
525
539
  <SessionDrawer
526
540
  open={sessionPanelOpen}
@@ -558,6 +572,9 @@ export function ChatPage() {
558
572
  <div className="relative flex-1 overflow-hidden">
559
573
  {messages.length === 0 ? (
560
574
  <div className="h-full overflow-y-auto">
575
+ <div className="px-4 pt-3 md:px-6">
576
+ <DigestCard />
577
+ </div>
561
578
  <WelcomeState onAction={handleWelcomeAction} />
562
579
  </div>
563
580
  ) : (
@@ -4,6 +4,7 @@ import { api } from "../../lib/api-client.js";
4
4
  import { useChatStore } from "../../stores/chat-store.js";
5
5
  import { SlidePanel } from "../layout/slide-panel.js";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { formatRelativeTime } from "./tool-views.js";
7
8
 
8
9
  interface SessionInfo {
9
10
  id: string;
@@ -215,15 +216,3 @@ function groupSessionsByDate(sessions: SessionInfo[]): GroupedSessions[] {
215
216
  .filter(([, sessions]) => sessions.length > 0)
216
217
  .map(([label, sessions]) => ({ label, sessions }));
217
218
  }
218
-
219
- function formatRelativeTime(ms: number): string {
220
- const diff = Date.now() - ms;
221
- const minutes = Math.floor(diff / 60000);
222
- if (minutes < 1) return "just now";
223
- if (minutes < 60) return `${minutes}m ago`;
224
- const hours = Math.floor(minutes / 60);
225
- if (hours < 24) return `${hours}h ago`;
226
- const days = Math.floor(hours / 24);
227
- if (days < 7) return `${days}d ago`;
228
- return new Date(ms).toLocaleDateString();
229
- }
@@ -0,0 +1,229 @@
1
+ import { useMemo } from "react";
2
+ import { ArrowLeft, Bot, Check, X } from "lucide-react";
3
+ import { motion } from "framer-motion";
4
+ import { useShallow } from "zustand/react/shallow";
5
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
6
+ import type { ActivitySpan, ActivitySpanEvent } from "@schlessera/brain-ui-sdk/protocol";
7
+
8
+ import { useActivityStore, childSpans, eventsFor, spanForTool } from "../../stores/activity-store.js";
9
+ import { useUIStore } from "../../stores/ui-store.js";
10
+ import { useChatStore, activeChat } from "../../stores/chat-store.js";
11
+ import { cn } from "../../lib/utils.js";
12
+ import { getToolLabel, formatDuration } from "./tool-views.js";
13
+ import { SpanStatusDot, spanToolLabel } from "../activity/span-bits.js";
14
+
15
+ /**
16
+ * Drill-in view of one subagent: the span tree under its Agent tool call,
17
+ * transcript excerpts interleaved by time, live while the subagent runs.
18
+ *
19
+ * Deliberately observation-shaped — no composer, no way to address the
20
+ * subagent — with ONE exception, decided during planning: an approval
21
+ * request raised by a tool inside this subagent renders actionable here as
22
+ * well as in the parent chat, because a fan-out stalled on an unaddressable
23
+ * approval is worse than a relaxed rule.
24
+ *
25
+ * When transcript forwarding is off (or the backend cannot forward), the
26
+ * view degrades to activity-only and says so, rather than looking empty.
27
+ */
28
+ export function SubagentView({
29
+ spanId,
30
+ onApproval,
31
+ }: {
32
+ spanId: string;
33
+ onApproval?: (toolUseId: string, approved: boolean) => void;
34
+ }) {
35
+ const popSubagentView = useUIStore((s) => s.popSubagentView);
36
+ const pushSubagentView = useUIStore((s) => s.pushSubagentView);
37
+ const span = useActivityStore((s) => spanForTool(s, spanId));
38
+ const children = useActivityStore(useShallow((s) => childSpans(s, spanId)));
39
+ const events = useActivityStore((s) => eventsFor(s, spanId));
40
+ const pendingApprovals = useChatStore(
41
+ useShallow((s) => {
42
+ const chat = activeChat(s);
43
+ const last = chat.messages.at(-1);
44
+ return (last?.toolCalls ?? []).filter((t) => t.status === "pending_approval");
45
+ })
46
+ );
47
+
48
+ const timelineItems = useMemo(
49
+ () => interleave(children, events),
50
+ [children, events]
51
+ );
52
+
53
+ if (!span) {
54
+ return (
55
+ <div className="flex flex-col gap-3 p-4">
56
+ <BackButton onClick={popSubagentView} />
57
+ <p className="text-sm text-muted-foreground">
58
+ No recorded activity for this subagent — it may have been pruned.
59
+ </p>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ const description = span.subagent?.description ?? null;
65
+ const subagentType = span.subagent?.type ?? null;
66
+ const summary = span.subagent?.summary ?? null;
67
+ const running = span.outcome === undefined;
68
+ const duration =
69
+ span.endedAt !== undefined ? formatDuration(span.endedAt - span.startedAt) : null;
70
+ const hasTranscript = events.some((e) => e.eventType.startsWith("transcript_"));
71
+ const approvalsHere = pendingApprovals.filter((t) =>
72
+ children.some((c) => c.spanId === t.id)
73
+ );
74
+
75
+ return (
76
+ <div className="flex h-full flex-col">
77
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
78
+ <BackButton onClick={popSubagentView} />
79
+ <Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
80
+ <div className="min-w-0 flex-1">
81
+ <div className="truncate text-sm font-medium">
82
+ {description ?? "Subagent"}
83
+ </div>
84
+ <div className="truncate text-[11px] text-muted-foreground">
85
+ {subagentType ?? "agent"}
86
+ {running ? " · running" : span.outcome ? ` · ${span.outcome}` : ""}
87
+ {duration ? ` · ${duration}` : ""}
88
+ </div>
89
+ </div>
90
+ {running && (
91
+ <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
92
+ )}
93
+ </div>
94
+
95
+ <div className="flex-1 space-y-1.5 overflow-y-auto p-4">
96
+ {approvalsHere.map((tool) => (
97
+ <div
98
+ key={tool.id}
99
+ className="rounded-lg border-2 border-primary/40 bg-primary/5 p-3 text-xs"
100
+ >
101
+ <div className="mb-2 font-medium">
102
+ Approval needed: {getToolLabel(tool.name)}
103
+ </div>
104
+ {onApproval && (
105
+ <div className="flex gap-2">
106
+ <button
107
+ onClick={() => onApproval(tool.id, true)}
108
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-110"
109
+ >
110
+ <Check className="h-3 w-3" /> Allow
111
+ </button>
112
+ <button
113
+ onClick={() => onApproval(tool.id, false)}
114
+ className="flex items-center gap-1.5 rounded-lg border border-destructive/30 px-4 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10"
115
+ >
116
+ <X className="h-3 w-3" /> Deny
117
+ </button>
118
+ </div>
119
+ )}
120
+ </div>
121
+ ))}
122
+
123
+ {timelineItems.length === 0 && (
124
+ <p className="text-xs text-muted-foreground">
125
+ {running ? "Waiting for the first step…" : "No recorded steps."}
126
+ </p>
127
+ )}
128
+
129
+ {timelineItems.map((item) =>
130
+ item.kind === "span" ? (
131
+ <SpanRow
132
+ key={item.span.spanId}
133
+ span={item.span}
134
+ onOpen={
135
+ item.span.kind === "subagent"
136
+ ? () => pushSubagentView(item.span.spanId)
137
+ : undefined
138
+ }
139
+ />
140
+ ) : (
141
+ <TranscriptRow key={`${item.event.spanId}:${item.event.eventIndex}`} event={item.event} />
142
+ )
143
+ )}
144
+
145
+ {!hasTranscript && children.length > 0 && (
146
+ <p className="pt-2 text-[11px] text-muted-foreground/60">
147
+ Activity only — this subagent's transcript was not captured.
148
+ </p>
149
+ )}
150
+ {summary && !running && (
151
+ <div className="mt-2 rounded-lg border border-border-subtle bg-surface p-3 text-xs">
152
+ {summary}
153
+ </div>
154
+ )}
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function BackButton({ onClick }: { onClick: () => void }) {
161
+ return (
162
+ <button
163
+ type="button"
164
+ onClick={onClick}
165
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
166
+ aria-label="Back"
167
+ >
168
+ <ArrowLeft className="h-4 w-4" />
169
+ </button>
170
+ );
171
+ }
172
+
173
+ function SpanRow({ span, onOpen }: { span: ActivitySpan; onOpen?: () => void }) {
174
+ const running = span.outcome === undefined;
175
+ const failed = isFailureOutcome(span.outcome);
176
+ const duration =
177
+ span.endedAt !== undefined
178
+ ? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
179
+ : null;
180
+ return (
181
+ <motion.div
182
+ initial={{ opacity: 0, y: 2 }}
183
+ animate={{ opacity: 1, y: 0 }}
184
+ className={cn(
185
+ "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
186
+ failed ? "text-destructive/80" : "text-muted-foreground",
187
+ onOpen && "cursor-pointer hover:text-foreground"
188
+ )}
189
+ onClick={onOpen}
190
+ >
191
+ <SpanStatusDot span={span} className="h-2 w-2" />
192
+ <span className="font-[family-name:var(--font-mono)] font-medium">
193
+ {spanToolLabel(span)}
194
+ </span>
195
+ {span.outcome && span.outcome !== "success" && (
196
+ <span className="text-[10px] uppercase">{span.outcome}</span>
197
+ )}
198
+ <span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
199
+ {duration ?? (running ? "…" : "")}
200
+ </span>
201
+ </motion.div>
202
+ );
203
+ }
204
+
205
+ function TranscriptRow({ event }: { event: ActivitySpanEvent }) {
206
+ const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
207
+ if (!text) return null;
208
+ return (
209
+ <div className="whitespace-pre-wrap rounded-md bg-surface px-3 py-2 text-xs text-foreground/80">
210
+ {text}
211
+ {event.truncated && <span className="text-muted-foreground/60"> …</span>}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ type TimelineItem =
217
+ | { kind: "span"; span: ActivitySpan; ts: number }
218
+ | { kind: "event"; event: ActivitySpanEvent; ts: number };
219
+
220
+ /** Child steps and transcript excerpts in one chronological stream. */
221
+ function interleave(spans: ActivitySpan[], events: ActivitySpanEvent[]): TimelineItem[] {
222
+ const items: TimelineItem[] = [
223
+ ...spans.map((span) => ({ kind: "span" as const, span, ts: span.startedAt })),
224
+ ...events
225
+ .filter((e) => e.eventType.startsWith("transcript_"))
226
+ .map((event) => ({ kind: "event" as const, event, ts: event.ts })),
227
+ ];
228
+ return items.sort((a, b) => a.ts - b.ts);
229
+ }
@@ -13,9 +13,14 @@ import { resolveToolRenderer } from "@schlessera/brain-ui-sdk/client";
13
13
  import type { ToolCall } from "../../stores/chat-store.js";
14
14
  import { cn } from "../../lib/utils.js";
15
15
  import { motion, AnimatePresence } from "framer-motion";
16
- import { getToolLabel, getTouchedFile, formatDuration } from "./tool-views.js";
16
+ import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
17
17
  import { registerBuiltinRenderers, GENERIC_RENDERER } from "./renderers/index.js";
18
18
  import { riskHints } from "./risk-hints.js";
19
+ import { useShallow } from "zustand/react/shallow";
20
+ import { useActivityStore, spanForTool, childSpans } from "../../stores/activity-store.js";
21
+ import { useUIStore } from "../../stores/ui-store.js";
22
+ import { useNow } from "../../hooks/use-now.js";
23
+ import { SpanStatusDot } from "../activity/span-bits.js";
19
24
 
20
25
  // Register the built-in renderer packs once. Registration is build-time; this
21
26
  // runs on module load.
@@ -127,13 +132,26 @@ function ToolCallEntry({
127
132
  toolCall.status === "pending_approval"
128
133
  );
129
134
  const prevStatus = useRef(toolCall.status);
135
+ // One clock for live and reloaded views: when the activity stream carries
136
+ // this call's span, its server-stamped timing wins over the client stamps
137
+ // (which don't exist at all for history-loaded messages). Selecting the
138
+ // span itself (a stable reference) rather than a derived timing object
139
+ // keeps every other entry from re-rendering on every activity delta.
140
+ const span = useActivityStore((s) => spanForTool(s, toolCall.id));
141
+ const timed: ToolCall = span
142
+ ? {
143
+ ...toolCall,
144
+ startedAt: span.waitUntil ?? span.startedAt,
145
+ ...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
146
+ }
147
+ : toolCall;
130
148
  // Resolve the renderer for this tool (backend-scoped exact -> global exact ->
131
149
  // shape-sniffing predicate). Falls back to the generic renderer.
132
150
  const renderer = resolveToolRenderer(toolCall, BACKEND_ID) ?? GENERIC_RENDERER;
133
151
  const Icon = renderer.icon ?? FileText;
134
152
  const isPending = toolCall.status === "pending_approval";
135
- const summary = renderer.summary?.(toolCall) ?? null;
136
- const meta = renderer.meta?.(toolCall) ?? null;
153
+ const summary = renderer.summary?.(timed) ?? null;
154
+ const meta = renderer.meta?.(timed) ?? null;
137
155
  const Input = renderer.Input;
138
156
  const Output = renderer.Output;
139
157
 
@@ -208,6 +226,9 @@ function ToolCallEntry({
208
226
  </span>
209
227
  </button>
210
228
 
229
+ {/* Live subagent state for Agent fan-outs, fed by the activity stream. */}
230
+ {toolCall.name === "Agent" && <SubagentEntryRows agentToolUseId={toolCall.id} />}
231
+
211
232
  {/* Expandable detail */}
212
233
  <AnimatePresence>
213
234
  {expanded && (
@@ -275,6 +296,43 @@ function ToolCallEntry({
275
296
  );
276
297
  }
277
298
 
299
+ /**
300
+ * Compact live status of one Agent fan-out: what the subagent is, its state,
301
+ * elapsed time and step count — with a tap opening the drill-in view. Data
302
+ * comes from the activity stream (session subscription); on a host without
303
+ * activity recording this renders nothing and the entry stays as before.
304
+ */
305
+ function SubagentEntryRows({ agentToolUseId }: { agentToolUseId: string }) {
306
+ const span = useActivityStore((s) => spanForTool(s, agentToolUseId));
307
+ const stepCount = useActivityStore(
308
+ useShallow((s) => childSpans(s, agentToolUseId).length)
309
+ );
310
+ const pushSubagentView = useUIStore((s) => s.pushSubagentView);
311
+ const now = useNow();
312
+ if (!span || span.kind !== "subagent") return null;
313
+
314
+ const description = span.subagent?.description ?? "subagent";
315
+ const tokens = span.subagent?.totalTokens ?? null;
316
+ const elapsed = formatDuration((span.endedAt ?? now) - span.startedAt);
317
+
318
+ return (
319
+ <button
320
+ type="button"
321
+ onClick={() => pushSubagentView(agentToolUseId)}
322
+ className="ml-6 flex w-[calc(100%-1.5rem)] items-center gap-2 rounded-md px-2 py-1 text-[11px] text-muted-foreground/80 transition-colors hover:text-foreground"
323
+ >
324
+ <SpanStatusDot span={span} />
325
+ <span className="truncate">{description}</span>
326
+ <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
327
+ {stepCount > 0 && `${stepCount} step${stepCount === 1 ? "" : "s"} · `}
328
+ {tokens !== null && `${formatTokenCount(tokens)} tok · `}
329
+ {elapsed}
330
+ </span>
331
+ <ChevronRight className="h-3 w-3 shrink-0" />
332
+ </button>
333
+ );
334
+ }
335
+
278
336
  function RiskHints({ toolCall }: { toolCall: ToolCall }) {
279
337
  const hints = riskHints(toolCall);
280
338
  if (hints.length === 0) return null;
@@ -68,6 +68,8 @@ function fileLabel(path: string): ReactNode {
68
68
  }
69
69
 
70
70
  export function formatDuration(ms: number): string {
71
+ // Server and browser clocks can skew; a negative duration is just 0.
72
+ ms = Math.max(0, ms);
71
73
  if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
72
74
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
73
75
  const m = Math.floor(ms / 60_000);
@@ -75,6 +77,26 @@ export function formatDuration(ms: number): string {
75
77
  return `${m}m ${s}s`;
76
78
  }
77
79
 
80
+ /** Compact token count: 1234 → "1k", 2_345_678 → "2.3M". */
81
+ export function formatTokenCount(n: number): string {
82
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
83
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
84
+ return String(n);
85
+ }
86
+
87
+ /** Relative timestamp for list rows ("3m ago"); dates beyond a week. */
88
+ export function formatRelativeTime(ms: number): string {
89
+ const diff = Date.now() - ms;
90
+ const minutes = Math.floor(diff / 60000);
91
+ if (minutes < 1) return "just now";
92
+ if (minutes < 60) return `${minutes}m ago`;
93
+ const hours = Math.floor(minutes / 60);
94
+ if (hours < 24) return `${hours}h ago`;
95
+ const days = Math.floor(hours / 24);
96
+ if (days < 7) return `${days}d ago`;
97
+ return new Date(ms).toLocaleDateString();
98
+ }
99
+
78
100
  export function countLines(text: string): number {
79
101
  let n = 1;
80
102
  for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;