@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,451 @@
1
+ import { create } from "zustand";
2
+ import type {
3
+ ActivitySpan,
4
+ ActivitySpanEvent,
5
+ ServerActivityDelta,
6
+ ServerActivitySnapshot,
7
+ } from "@schlessera/brain-ui-sdk/protocol";
8
+ import { api, type ActivityIntent } from "../lib/api-client.js";
9
+ import { registerDevHandle } from "../config.js";
10
+
11
+ /** How many finished runs' details are fetched when a session opens. */
12
+ const HISTORY_RUN_LIMIT = 10;
13
+
14
+ /** Bound on the mirror: how many TERMINAL runs stay held. Live runs never count. */
15
+ const MAX_TERMINAL_RUNS = 50;
16
+
17
+ /**
18
+ * Client mirror of the server's activity record, fed by the snapshot-then-
19
+ * delta stream. The server computes; this store only holds and indexes what
20
+ * arrived — no aggregation, no derived rollups (those come from the REST
21
+ * activity API).
22
+ *
23
+ * Ordering contract (AE6): a snapshot sets the per-run high-water seq; a
24
+ * delta at or below it is DISCARDED (its content is already in the
25
+ * snapshot). Applying a delta is idempotent — span deltas upsert by spanId,
26
+ * event deltas insert-if-absent by (spanId, eventIndex) — so a duplicate
27
+ * delivery can never corrupt state. The guard is symmetric: a snapshot
28
+ * whose per-run high-water is STRICTLY LOWER than what is held (a stale
29
+ * frame racing a reconnect) is skipped for that run; an equal value still
30
+ * applies, so chunked append frames keep working.
31
+ *
32
+ * Bounded mirror: at most MAX_TERMINAL_RUNS finished runs are held — once a
33
+ * run past the cap, the oldest terminal runs (by root start time) are
34
+ * evicted wholesale (spans, events, high-water, reverse index). Live runs
35
+ * are never evicted; evicted history remains fetchable over REST.
36
+ */
37
+ interface ActivityState {
38
+ /** server_hello said this host records activity. */
39
+ supported: boolean;
40
+ /** Session ids this connection has an activity subscription for. */
41
+ subscribed: Record<string, true>;
42
+ /** Bumped on every new connection so subscribe effects re-send. */
43
+ connectionEpoch: number;
44
+ /** Live span state per run, keyed runId -> spanId. */
45
+ spans: Record<string, Record<string, ActivitySpan>>;
46
+ /** Ordered events per span (already sorted by eventIndex). */
47
+ events: Record<string, ActivitySpanEvent[]>;
48
+ /** Per-run high-water seq from the snapshot; deltas at or below drop. */
49
+ highWater: Record<string, number>;
50
+ /**
51
+ * Newest APPLIED delta seq per run. Kept apart from `highWater` on
52
+ * purpose: the snapshot staleness guard compares against the max of both
53
+ * (a snapshot older than an applied delta must not roll it back), while
54
+ * the delta gate keeps comparing against the snapshot floor alone so
55
+ * out-of-order delta delivery stays tolerated (insert-if-absent absorbs it).
56
+ */
57
+ deltaSeq: Record<string, number>;
58
+ /** spanId -> runId reverse index, for timing lookups by toolUseId. */
59
+ spanRun: Record<string, string>;
60
+
61
+ /** Unacknowledged notification intents — the guaranteed-tier inbox. */
62
+ inbox: ActivityIntent[];
63
+ loadInbox: () => Promise<void>;
64
+ acknowledgeIntent: (id: number) => Promise<void>;
65
+ acknowledgeAllIntents: () => Promise<void>;
66
+ setSupported: (supported: boolean) => void;
67
+ /** New connection: server-side subscriptions are gone; re-subscribe lazily. */
68
+ resetSubscriptions: () => void;
69
+ bumpConnectionEpoch: () => void;
70
+ markSubscribed: (sessionId: string) => void;
71
+ applySnapshot: (msg: ServerActivitySnapshot) => void;
72
+ applyDelta: (msg: ServerActivityDelta) => void;
73
+ clear: () => void;
74
+ }
75
+
76
+ export const useActivityStore = create<ActivityState>((set, get) => ({
77
+ supported: false,
78
+ subscribed: {},
79
+ connectionEpoch: 0,
80
+ spans: {},
81
+ events: {},
82
+ highWater: {},
83
+ deltaSeq: {},
84
+ spanRun: {},
85
+
86
+ inbox: [],
87
+
88
+ loadInbox: async () => {
89
+ try {
90
+ const { intents } = await api.activityInbox();
91
+ set({ inbox: intents });
92
+ } catch {
93
+ // Badge absence over a crash — the inbox is reachable from Activity.
94
+ }
95
+ },
96
+
97
+ acknowledgeIntent: async (id) => {
98
+ set((s) => ({ inbox: s.inbox.filter((i) => i.id !== id) }));
99
+ try {
100
+ await api.activityInboxAck(id);
101
+ } catch {
102
+ // Optimistic removal stands; the next load re-syncs.
103
+ }
104
+ },
105
+
106
+ acknowledgeAllIntents: async () => {
107
+ set({ inbox: [] });
108
+ try {
109
+ await api.activityInboxAckAll();
110
+ } catch {
111
+ // Same optimistic policy.
112
+ }
113
+ },
114
+
115
+ setSupported: (supported) => {
116
+ set({ supported });
117
+ if (supported) startInboxPolling();
118
+ },
119
+
120
+ resetSubscriptions: () => set({ subscribed: {} }),
121
+
122
+ bumpConnectionEpoch: () => set((s) => ({ connectionEpoch: s.connectionEpoch + 1 })),
123
+
124
+ markSubscribed: (sessionId) =>
125
+ set((s) => ({ subscribed: { ...s.subscribed, [sessionId]: true } })),
126
+
127
+ applySnapshot: (msg) => {
128
+ set((s) => {
129
+ const maps: MirrorMaps = {
130
+ spans: { ...s.spans },
131
+ events: { ...s.events },
132
+ highWater: { ...s.highWater },
133
+ deltaSeq: { ...s.deltaSeq },
134
+ spanRun: { ...s.spanRun },
135
+ };
136
+ // Staleness guard: a run whose incoming high-water is strictly below
137
+ // what is held (snapshot floor or an applied delta) carries older
138
+ // state than what already applied — skip its spans/events. Equal
139
+ // still applies (chunked append frames).
140
+ const stale = new Set<string>();
141
+ for (const [runId, seq] of Object.entries(msg.highWaterSeq)) {
142
+ const held = Math.max(maps.highWater[runId] ?? 0, maps.deltaSeq[runId] ?? 0);
143
+ if (seq < held) stale.add(runId);
144
+ else maps.highWater[runId] = seq;
145
+ }
146
+ for (const span of msg.spans) {
147
+ if (stale.has(span.runId)) continue;
148
+ maps.spans[span.runId] = { ...(maps.spans[span.runId] ?? {}), [span.spanId]: span };
149
+ maps.spanRun[span.spanId] = span.runId;
150
+ }
151
+ for (const event of msg.events) {
152
+ const runId = maps.spanRun[event.spanId];
153
+ if (runId !== undefined && stale.has(runId)) continue;
154
+ maps.events[event.spanId] = insertEvent(maps.events[event.spanId], event);
155
+ }
156
+ // Scope eviction: a fresh session/index snapshot is authoritative for
157
+ // its scope. A held run that still looks live but is absent from the
158
+ // frame's high-water map is a ghost — the server says it is not
159
+ // running any more (its REST history remains fetchable).
160
+ if (!msg.append && (msg.view === "session" || msg.view === "index")) {
161
+ for (const [runId, byId] of Object.entries(maps.spans)) {
162
+ if (runId in msg.highWaterSeq) continue;
163
+ const root = rootSpanOf(byId);
164
+ if (!root || root.outcome !== undefined) continue;
165
+ const inScope = msg.view === "index" || root.sessionId === msg.sessionId;
166
+ if (inScope) evictRun(maps, runId);
167
+ }
168
+ }
169
+ pruneTerminalRuns(maps);
170
+ return maps;
171
+ });
172
+ },
173
+
174
+ applyDelta: (msg) => {
175
+ const s = get();
176
+ if (msg.seq <= (s.highWater[msg.runId] ?? 0)) return;
177
+ if (msg.span) {
178
+ const span = msg.span;
179
+ set((prev) => ({
180
+ spans: {
181
+ ...prev.spans,
182
+ [msg.runId]: { ...(prev.spans[msg.runId] ?? {}), [span.spanId]: span },
183
+ },
184
+ // Recording the applied seq keeps the snapshot staleness guard
185
+ // honest: a snapshot frame older than this delta must not roll it back.
186
+ deltaSeq: {
187
+ ...prev.deltaSeq,
188
+ [msg.runId]: Math.max(prev.deltaSeq[msg.runId] ?? 0, msg.seq),
189
+ },
190
+ // The mapping is almost always already present — re-spreading it on
191
+ // every delta would churn a large object for nothing.
192
+ ...(prev.spanRun[span.spanId] === msg.runId
193
+ ? {}
194
+ : { spanRun: { ...prev.spanRun, [span.spanId]: msg.runId } }),
195
+ }));
196
+ // A root span closing may push the mirror past the terminal-run cap.
197
+ if (span.parentSpanId === undefined && span.outcome !== undefined) {
198
+ const prev = get();
199
+ const maps: MirrorMaps = {
200
+ spans: { ...prev.spans },
201
+ events: { ...prev.events },
202
+ highWater: { ...prev.highWater },
203
+ deltaSeq: { ...prev.deltaSeq },
204
+ spanRun: { ...prev.spanRun },
205
+ };
206
+ if (pruneTerminalRuns(maps)) set(maps);
207
+ }
208
+ } else if (msg.event) {
209
+ const event = msg.event;
210
+ set((prev) => ({
211
+ events: { ...prev.events, [event.spanId]: insertEvent(prev.events[event.spanId], event) },
212
+ deltaSeq: {
213
+ ...prev.deltaSeq,
214
+ [msg.runId]: Math.max(prev.deltaSeq[msg.runId] ?? 0, msg.seq),
215
+ },
216
+ ...(prev.spanRun[event.spanId] !== undefined
217
+ ? {}
218
+ : { spanRun: { ...prev.spanRun, [event.spanId]: msg.runId } }),
219
+ }));
220
+ }
221
+ },
222
+
223
+ clear: () => set({ spans: {}, events: {}, highWater: {}, deltaSeq: {}, spanRun: {} }),
224
+ }));
225
+
226
+ /** One badge poller per page lifetime — the inbox is cheap and the badge
227
+ * must be honest even when the Activity surface never opens. */
228
+ let inboxPoller: ReturnType<typeof setInterval> | null = null;
229
+ function startInboxPolling() {
230
+ if (inboxPoller) return;
231
+ void useActivityStore.getState().loadInbox();
232
+ inboxPoller = setInterval(() => {
233
+ void useActivityStore.getState().loadInbox();
234
+ }, 60_000);
235
+ }
236
+
237
+ /** Runs whose history has been fetched already (per page lifetime). */
238
+ const loadedHistorySessions = new Set<string>();
239
+
240
+ /**
241
+ * Pull a session's recent FINISHED runs from the REST activity API into the
242
+ * store. The live subscription's snapshot carries open runs only; this is
243
+ * what makes duration badges on history-loaded messages real (AE2) — span
244
+ * ids are tool-use ids, so the timeline's timing lookup starts hitting.
245
+ * Fire-and-forget; a failure just leaves badges absent, as before.
246
+ */
247
+ export async function loadSessionActivityHistory(sessionId: string): Promise<void> {
248
+ if (loadedHistorySessions.has(sessionId)) return;
249
+ loadedHistorySessions.add(sessionId);
250
+ try {
251
+ const { history } = await api.activityRuns({ session: sessionId, limit: HISTORY_RUN_LIMIT });
252
+ const store = useActivityStore.getState();
253
+ const details = await Promise.all(
254
+ history
255
+ .slice(0, HISTORY_RUN_LIMIT)
256
+ .filter((run) => !run.detailPruned)
257
+ .map((run) => api.activityRun(run.runId))
258
+ );
259
+ for (const detail of details) {
260
+ if (detail.detailPruned || !detail.spans) continue;
261
+ store.applySnapshot({
262
+ type: "activity_snapshot",
263
+ view: "run",
264
+ runId: detail.runId,
265
+ spans: detail.spans,
266
+ events: detail.events ?? [],
267
+ highWaterSeq: { [detail.runId]: detail.highWaterSeq ?? 0 },
268
+ });
269
+ }
270
+ } catch {
271
+ // History timing is an enhancement; the transcript renders without it.
272
+ loadedHistorySessions.delete(sessionId);
273
+ }
274
+ }
275
+
276
+ /** Runs whose payload events have been backfilled over REST (per page lifetime). */
277
+ const payloadLoadedRuns = new Set<string>();
278
+
279
+ /**
280
+ * Backfill one finished run's tool payload events into the mirror. The
281
+ * history fetch above and the run-list default deliberately skip payload
282
+ * bodies — they exist for duration badges, not drill-ins — so the first
283
+ * expanded payload view of a history span pulls the full detail
284
+ * (`include=payloads`) and merges it. Live runs never need this: their
285
+ * payload events ride the delta stream. The merge is safe because a snapshot
286
+ * at the same high-water still applies and events insert-if-absent.
287
+ */
288
+ export async function loadSpanPayloads(spanId: string): Promise<void> {
289
+ const state = useActivityStore.getState();
290
+ const runId = state.spanRun[spanId];
291
+ if (!runId || payloadLoadedRuns.has(runId)) return;
292
+ const root = rootSpanOf(state.spans[runId] ?? {});
293
+ if (!root || root.outcome === undefined) return;
294
+ payloadLoadedRuns.add(runId);
295
+ try {
296
+ const detail = await api.activityRun(runId, { includePayloads: true });
297
+ if (detail.detailPruned || !detail.spans) return;
298
+ useActivityStore.getState().applySnapshot({
299
+ type: "activity_snapshot",
300
+ view: "run",
301
+ runId,
302
+ spans: detail.spans,
303
+ events: detail.events ?? [],
304
+ highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
305
+ });
306
+ } catch {
307
+ // Payloads are an enhancement; the row keeps its no-payload notice.
308
+ payloadLoadedRuns.delete(runId);
309
+ }
310
+ }
311
+
312
+ /** The four indexes that make up the mirror, mutated together during merges. */
313
+ interface MirrorMaps {
314
+ spans: Record<string, Record<string, ActivitySpan>>;
315
+ events: Record<string, ActivitySpanEvent[]>;
316
+ highWater: Record<string, number>;
317
+ deltaSeq: Record<string, number>;
318
+ spanRun: Record<string, string>;
319
+ }
320
+
321
+ function rootSpanOf(byId: Record<string, ActivitySpan>): ActivitySpan | undefined {
322
+ return Object.values(byId).find((span) => span.parentSpanId === undefined);
323
+ }
324
+
325
+ /** Drop a whole run from the mirror — spans, events, high-water, reverse index. */
326
+ function evictRun(maps: MirrorMaps, runId: string): void {
327
+ for (const spanId of Object.keys(maps.spans[runId] ?? {})) {
328
+ delete maps.events[spanId];
329
+ delete maps.spanRun[spanId];
330
+ }
331
+ delete maps.spans[runId];
332
+ delete maps.highWater[runId];
333
+ delete maps.deltaSeq[runId];
334
+ }
335
+
336
+ /**
337
+ * Enforce the terminal-run bound in place. Returns whether anything was
338
+ * evicted. Runs whose root span is still open (or unknown) are never touched.
339
+ */
340
+ function pruneTerminalRuns(maps: MirrorMaps): boolean {
341
+ const terminal: Array<{ runId: string; startedAt: number }> = [];
342
+ for (const [runId, byId] of Object.entries(maps.spans)) {
343
+ const root = rootSpanOf(byId);
344
+ if (root && root.outcome !== undefined) {
345
+ terminal.push({ runId, startedAt: root.startedAt });
346
+ }
347
+ }
348
+ if (terminal.length <= MAX_TERMINAL_RUNS) return false;
349
+ terminal.sort((a, b) => a.startedAt - b.startedAt);
350
+ for (const { runId } of terminal.slice(0, terminal.length - MAX_TERMINAL_RUNS)) {
351
+ evictRun(maps, runId);
352
+ }
353
+ return true;
354
+ }
355
+
356
+ function insertEvent(
357
+ list: ActivitySpanEvent[] | undefined,
358
+ event: ActivitySpanEvent
359
+ ): ActivitySpanEvent[] {
360
+ const existing = list ?? [];
361
+ if (existing.some((e) => e.eventIndex === event.eventIndex)) return existing;
362
+ const next = [...existing, event];
363
+ next.sort((a, b) => a.eventIndex - b.eventIndex);
364
+ return next;
365
+ }
366
+
367
+ // --- Selectors (plain functions over the state, like activeChat) ---
368
+
369
+ /** All spans of a run, roots first, then by start time. */
370
+ export function runSpans(state: ActivityState, runId: string): ActivitySpan[] {
371
+ const byId = state.spans[runId];
372
+ if (!byId) return [];
373
+ return Object.values(byId).sort(
374
+ (a, b) =>
375
+ (a.parentSpanId ? 1 : 0) - (b.parentSpanId ? 1 : 0) || a.startedAt - b.startedAt
376
+ );
377
+ }
378
+
379
+ /** Direct children of a span (e.g. the tool calls inside a subagent). */
380
+ export function childSpans(state: ActivityState, parentSpanId: string): ActivitySpan[] {
381
+ const runId = state.spanRun[parentSpanId];
382
+ if (!runId) return [];
383
+ return Object.values(state.spans[runId] ?? {})
384
+ .filter((s) => s.parentSpanId === parentSpanId)
385
+ .sort((a, b) => a.startedAt - b.startedAt);
386
+ }
387
+
388
+ /** The subagent spans spawned by a turn's Agent tool calls, live status included. */
389
+ export function subagentSpans(state: ActivityState, sessionId: string): ActivitySpan[] {
390
+ const out: ActivitySpan[] = [];
391
+ for (const byId of Object.values(state.spans)) {
392
+ for (const span of Object.values(byId)) {
393
+ if (span.kind === "subagent" && span.sessionId === sessionId) out.push(span);
394
+ }
395
+ }
396
+ return out.sort((a, b) => a.startedAt - b.startedAt);
397
+ }
398
+
399
+ /** Stable empty result for `eventsFor` — a fresh `[]` per call would defeat
400
+ * reference-equality checks in zustand selectors. */
401
+ export const EMPTY_EVENTS: ActivitySpanEvent[] = Object.freeze(
402
+ [] as ActivitySpanEvent[]
403
+ ) as ActivitySpanEvent[];
404
+
405
+ /** Ordered events of a span; the shared frozen empty array when none. */
406
+ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
407
+ return state.events[spanId] ?? EMPTY_EVENTS;
408
+ }
409
+
410
+ /**
411
+ * The recorded input/output payload events of a tool span (AE7). Returns a
412
+ * fresh array per call — subscribe through `useShallow` (like `childSpans`).
413
+ */
414
+ export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
415
+ const events = state.events[spanId];
416
+ if (!events) return EMPTY_EVENTS;
417
+ const payloads = events.filter(
418
+ (e) => e.eventType === "tool_input" || e.eventType === "tool_output"
419
+ );
420
+ return payloads.length > 0 ? payloads : EMPTY_EVENTS;
421
+ }
422
+
423
+ /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
424
+ export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
425
+ const runId = state.spanRun[toolUseId];
426
+ return runId ? (state.spans[runId]?.[toolUseId] ?? null) : null;
427
+ }
428
+
429
+ /**
430
+ * Server-stamped timing for a tool call. One clock for live and reloaded
431
+ * views: when the activity stream carries the span, its timestamps win over
432
+ * client stamps (AE2 — live and revisit must render identical timings).
433
+ * `waitUntil` marks the approval-wait/execution boundary.
434
+ */
435
+ export function timingFor(
436
+ state: ActivityState,
437
+ toolUseId: string
438
+ ): { startedAt: number; endedAt?: number } | null {
439
+ const span = spanForTool(state, toolUseId);
440
+ if (!span) return null;
441
+ return {
442
+ startedAt: span.waitUntil ?? span.startedAt,
443
+ ...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
444
+ };
445
+ }
446
+
447
+ registerDevHandle(() => {
448
+ if (typeof window === "undefined") return;
449
+ (window as unknown as { __activityStore?: typeof useActivityStore }).__activityStore =
450
+ useActivityStore;
451
+ });
@@ -4,10 +4,18 @@ import { create } from "zustand";
4
4
  export type SettingsTab = "models" | "security";
5
5
 
6
6
  /** Full-screen surface currently shown inside the AppShell. */
7
- export type ActiveView = "chat" | "graph";
7
+ export type ActiveView = "chat" | "graph" | "activity";
8
8
 
9
9
  interface UIState {
10
10
  activeView: ActiveView;
11
+ /**
12
+ * Drill-in stack of open subagent views (Agent tool-call span ids). A
13
+ * nested subagent pushes; back pops. Kept here rather than component state
14
+ * so the Activity surface and the chat timeline share one drill-in.
15
+ */
16
+ subagentStack: string[];
17
+ pushSubagentView: (spanId: string) => void;
18
+ popSubagentView: () => void;
11
19
  sessionPanelOpen: boolean;
12
20
  syncPanelOpen: boolean;
13
21
  whatsupPanelOpen: boolean;
@@ -52,6 +60,10 @@ export const useUIStore = create<UIState>((set) => ({
52
60
  ...CLOSED,
53
61
  activeView: "chat",
54
62
  settingsTab: "models",
63
+ subagentStack: [],
64
+ pushSubagentView: (spanId) =>
65
+ set((s) => ({ subagentStack: [...s.subagentStack, spanId] })),
66
+ popSubagentView: () => set((s) => ({ subagentStack: s.subagentStack.slice(0, -1) })),
55
67
  setActiveView: (view) => set({ ...CLOSED, activeView: view }),
56
68
  toggleSessionPanel: () =>
57
69
  set((s) => ({ ...CLOSED, sessionPanelOpen: !s.sessionPanelOpen })),