@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.
- package/dist/components/activity/activity-page.d.ts +11 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -0
- package/dist/components/activity/activity-page.js +243 -0
- package/dist/components/activity/activity-page.js.map +1 -0
- package/dist/components/activity/digest-card.d.ts +10 -0
- package/dist/components/activity/digest-card.d.ts.map +1 -0
- package/dist/components/activity/digest-card.js +55 -0
- package/dist/components/activity/digest-card.js.map +1 -0
- package/dist/components/activity/push-toggle.d.ts +2 -0
- package/dist/components/activity/push-toggle.d.ts.map +1 -0
- package/dist/components/activity/push-toggle.js +121 -0
- package/dist/components/activity/push-toggle.js.map +1 -0
- package/dist/components/activity/span-bits.d.ts +76 -0
- package/dist/components/activity/span-bits.d.ts.map +1 -0
- package/dist/components/activity/span-bits.js +127 -0
- package/dist/components/activity/span-bits.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +4 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +1 -15
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/subagent-view.d.ts +18 -0
- package/dist/components/chat/subagent-view.d.ts.map +1 -0
- package/dist/components/chat/subagent-view.js +83 -0
- package/dist/components/chat/subagent-view.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +40 -4
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +4 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +26 -0
- package/dist/components/chat/tool-views.js.map +1 -1
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
- package/dist/components/layout/mobile-tab-bar.js +10 -4
- package/dist/components/layout/mobile-tab-bar.js.map +1 -1
- package/dist/components/layout/side-rail.d.ts.map +1 -1
- package/dist/components/layout/side-rail.js +7 -4
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts +15 -0
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +100 -21
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/hooks/use-now.d.ts +8 -0
- package/dist/hooks/use-now.d.ts.map +1 -0
- package/dist/hooks/use-now.js +32 -0
- package/dist/hooks/use-now.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +46 -0
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api-client.d.ts +67 -1
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +41 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/activity-store.d.ts +108 -0
- package/dist/stores/activity-store.d.ts.map +1 -0
- package/dist/stores/activity-store.js +359 -0
- package/dist/stores/activity-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +9 -1
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +3 -0
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +623 -0
- package/src/components/activity/digest-card.tsx +105 -0
- package/src/components/activity/push-toggle.tsx +157 -0
- package/src/components/activity/span-bits.tsx +194 -0
- package/src/components/chat/chat-page.tsx +17 -0
- package/src/components/chat/session-drawer.tsx +1 -12
- package/src/components/chat/subagent-view.tsx +239 -0
- package/src/components/chat/tool-call-timeline.tsx +61 -3
- package/src/components/chat/tool-views.tsx +22 -0
- package/src/components/layout/mobile-tab-bar.tsx +25 -5
- package/src/components/layout/side-rail.tsx +17 -0
- package/src/components/settings/models-tab.tsx +135 -21
- package/src/hooks/use-now.ts +32 -0
- package/src/hooks/use-websocket.ts +44 -0
- package/src/index.ts +1 -0
- package/src/lib/api-client.ts +111 -0
- package/src/stores/activity-store.ts +451 -0
- 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
|
+
});
|
package/src/stores/ui-store.ts
CHANGED
|
@@ -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 })),
|