@schlessera/brain-ui-react 0.34.0 → 0.34.1

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 (157) hide show
  1. package/README.md +5 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -1
  3. package/dist/components/activity/activity-page.js +8 -149
  4. package/dist/components/activity/activity-page.js.map +1 -1
  5. package/dist/components/activity/activity-rollups.d.ts +5 -0
  6. package/dist/components/activity/activity-rollups.d.ts.map +1 -0
  7. package/dist/components/activity/activity-rollups.js +39 -0
  8. package/dist/components/activity/activity-rollups.js.map +1 -0
  9. package/dist/components/activity/activity-run-detail.d.ts +11 -0
  10. package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
  11. package/dist/components/activity/activity-run-detail.js +111 -0
  12. package/dist/components/activity/activity-run-detail.js.map +1 -0
  13. package/dist/components/activity/activity-run-list.d.ts +15 -0
  14. package/dist/components/activity/activity-run-list.d.ts.map +1 -0
  15. package/dist/components/activity/activity-run-list.js +25 -0
  16. package/dist/components/activity/activity-run-list.js.map +1 -0
  17. package/dist/components/chat/brain-markdown-code.d.ts +4 -0
  18. package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
  19. package/dist/components/chat/brain-markdown-code.js +47 -0
  20. package/dist/components/chat/brain-markdown-code.js.map +1 -0
  21. package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
  22. package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
  23. package/dist/components/chat/brain-markdown-entities.js +93 -0
  24. package/dist/components/chat/brain-markdown-entities.js.map +1 -0
  25. package/dist/components/chat/brain-markdown-links.d.ts +36 -0
  26. package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
  27. package/dist/components/chat/brain-markdown-links.js +176 -0
  28. package/dist/components/chat/brain-markdown-links.js.map +1 -0
  29. package/dist/components/chat/brain-markdown-share.d.ts +15 -0
  30. package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
  31. package/dist/components/chat/brain-markdown-share.js +40 -0
  32. package/dist/components/chat/brain-markdown-share.js.map +1 -0
  33. package/dist/components/chat/brain-markdown.d.ts +1 -34
  34. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  35. package/dist/components/chat/brain-markdown.js +9 -402
  36. package/dist/components/chat/brain-markdown.js.map +1 -1
  37. package/dist/components/chat/composer.d.ts.map +1 -1
  38. package/dist/components/chat/composer.js +14 -1
  39. package/dist/components/chat/composer.js.map +1 -1
  40. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  41. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  42. package/dist/components/chat/use-markdown-highlight.js +56 -0
  43. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  44. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  45. package/dist/components/connectivity/connection-gate.js +41 -7
  46. package/dist/components/connectivity/connection-gate.js.map +1 -1
  47. package/dist/components/connectivity/connection-state.d.ts +12 -0
  48. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  49. package/dist/components/connectivity/connection-state.js +23 -0
  50. package/dist/components/connectivity/connection-state.js.map +1 -0
  51. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  52. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  53. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  54. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  55. package/dist/components/graph/graph-controls.js +4 -1
  56. package/dist/components/graph/graph-controls.js.map +1 -1
  57. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  58. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  59. package/dist/components/graph/graph-maintenance.js +115 -0
  60. package/dist/components/graph/graph-maintenance.js.map +1 -0
  61. package/dist/components/graph/graph-page.d.ts.map +1 -1
  62. package/dist/components/graph/graph-page.js +11 -306
  63. package/dist/components/graph/graph-page.js.map +1 -1
  64. package/dist/components/graph/graph-scene.d.ts +8 -0
  65. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  66. package/dist/components/graph/graph-scene.js +208 -0
  67. package/dist/components/graph/graph-scene.js.map +1 -0
  68. package/dist/components/graph/graph-spinner.d.ts +2 -0
  69. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  70. package/dist/components/graph/graph-spinner.js +6 -0
  71. package/dist/components/graph/graph-spinner.js.map +1 -0
  72. package/dist/hooks/use-vpn-status.d.ts +5 -0
  73. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  74. package/dist/hooks/use-vpn-status.js +61 -29
  75. package/dist/hooks/use-vpn-status.js.map +1 -1
  76. package/dist/hooks/use-websocket.d.ts +3 -2
  77. package/dist/hooks/use-websocket.d.ts.map +1 -1
  78. package/dist/hooks/use-websocket.js +61 -252
  79. package/dist/hooks/use-websocket.js.map +1 -1
  80. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  81. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  82. package/dist/hooks/websocket-handlers/activity.js +10 -0
  83. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  84. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  85. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  86. package/dist/hooks/websocket-handlers/chat.js +175 -0
  87. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  88. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  89. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  90. package/dist/hooks/websocket-handlers/connection.js +27 -0
  91. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  92. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  93. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  94. package/dist/hooks/websocket-handlers/index.js +19 -0
  95. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  96. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  97. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  98. package/dist/hooks/websocket-handlers/mask.js +14 -0
  99. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  100. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  101. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  102. package/dist/hooks/websocket-handlers/provider.js +31 -0
  103. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  104. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  105. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  106. package/dist/hooks/websocket-handlers/types.js +2 -0
  107. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  108. package/dist/stores/activity-store-helpers.d.ts +50 -0
  109. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  110. package/dist/stores/activity-store-helpers.js +143 -0
  111. package/dist/stores/activity-store-helpers.js.map +1 -0
  112. package/dist/stores/activity-store-types.d.ts +48 -0
  113. package/dist/stores/activity-store-types.d.ts.map +1 -0
  114. package/dist/stores/activity-store-types.js +2 -0
  115. package/dist/stores/activity-store-types.js.map +1 -0
  116. package/dist/stores/activity-store.d.ts +2 -78
  117. package/dist/stores/activity-store.d.ts.map +1 -1
  118. package/dist/stores/activity-store.js +22 -142
  119. package/dist/stores/activity-store.js.map +1 -1
  120. package/dist/stores/connection-store.d.ts +14 -2
  121. package/dist/stores/connection-store.d.ts.map +1 -1
  122. package/dist/stores/connection-store.js +17 -0
  123. package/dist/stores/connection-store.js.map +1 -1
  124. package/dist/styles.css +1 -1
  125. package/package.json +2 -2
  126. package/src/components/activity/activity-page.tsx +6 -477
  127. package/src/components/activity/activity-rollups.tsx +94 -0
  128. package/src/components/activity/activity-run-detail.tsx +301 -0
  129. package/src/components/activity/activity-run-list.tsx +97 -0
  130. package/src/components/chat/brain-markdown-code.tsx +60 -0
  131. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  132. package/src/components/chat/brain-markdown-links.tsx +247 -0
  133. package/src/components/chat/brain-markdown-share.ts +47 -0
  134. package/src/components/chat/brain-markdown.tsx +27 -535
  135. package/src/components/chat/composer.tsx +14 -1
  136. package/src/components/chat/use-markdown-highlight.ts +63 -0
  137. package/src/components/connectivity/connection-gate.tsx +64 -13
  138. package/src/components/connectivity/connection-state.ts +37 -0
  139. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  140. package/src/components/graph/graph-controls.tsx +4 -1
  141. package/src/components/graph/graph-maintenance.tsx +304 -0
  142. package/src/components/graph/graph-page.tsx +11 -840
  143. package/src/components/graph/graph-scene.tsx +548 -0
  144. package/src/components/graph/graph-spinner.tsx +9 -0
  145. package/src/hooks/use-vpn-status.ts +64 -31
  146. package/src/hooks/use-websocket.ts +70 -273
  147. package/src/hooks/websocket-handlers/activity.ts +13 -0
  148. package/src/hooks/websocket-handlers/chat.ts +210 -0
  149. package/src/hooks/websocket-handlers/connection.ts +30 -0
  150. package/src/hooks/websocket-handlers/index.ts +59 -0
  151. package/src/hooks/websocket-handlers/mask.ts +17 -0
  152. package/src/hooks/websocket-handlers/provider.ts +35 -0
  153. package/src/hooks/websocket-handlers/types.ts +27 -0
  154. package/src/stores/activity-store-helpers.ts +164 -0
  155. package/src/stores/activity-store-types.ts +55 -0
  156. package/src/stores/activity-store.ts +21 -217
  157. package/src/stores/connection-store.ts +33 -2
@@ -0,0 +1,59 @@
1
+ import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
+ import { activityFrameHandlers } from "./activity.js";
3
+ import { chatFrameHandlers } from "./chat.js";
4
+ import { connectionFrameHandlers } from "./connection.js";
5
+ import { maskFrameHandlers } from "./mask.js";
6
+ import { providerFrameHandlers } from "./provider.js";
7
+ import type {
8
+ DispatchContext,
9
+ ServerMessageHandlerMap,
10
+ ServerMessageType,
11
+ } from "./types.js";
12
+
13
+ type HandlerKeys<T> = T extends unknown ? keyof T : never;
14
+ type ClaimedFrame = HandlerKeys<
15
+ | typeof chatFrameHandlers
16
+ | typeof activityFrameHandlers
17
+ | typeof maskFrameHandlers
18
+ | typeof providerFrameHandlers
19
+ | typeof connectionFrameHandlers
20
+ >;
21
+ type DuplicateFrame =
22
+ | Extract<
23
+ keyof typeof chatFrameHandlers,
24
+ | keyof typeof activityFrameHandlers
25
+ | keyof typeof maskFrameHandlers
26
+ | keyof typeof providerFrameHandlers
27
+ | keyof typeof connectionFrameHandlers
28
+ >
29
+ | Extract<
30
+ keyof typeof activityFrameHandlers,
31
+ | keyof typeof maskFrameHandlers
32
+ | keyof typeof providerFrameHandlers
33
+ | keyof typeof connectionFrameHandlers
34
+ >
35
+ | Extract<
36
+ keyof typeof maskFrameHandlers,
37
+ keyof typeof providerFrameHandlers | keyof typeof connectionFrameHandlers
38
+ >
39
+ | Extract<keyof typeof providerFrameHandlers, keyof typeof connectionFrameHandlers>;
40
+
41
+ type Assert<T extends true> = T;
42
+ type Equal<A, B> = [A, B] extends [B, A] ? true : false;
43
+ type _EveryFrameClaimed = Assert<Equal<ClaimedFrame, ServerMessageType>>;
44
+ type _NoFrameClaimedTwice = Assert<Equal<DuplicateFrame, never>>;
45
+
46
+ const serverMessageHandlers = {
47
+ ...chatFrameHandlers,
48
+ ...activityFrameHandlers,
49
+ ...maskFrameHandlers,
50
+ ...providerFrameHandlers,
51
+ ...connectionFrameHandlers,
52
+ } satisfies ServerMessageHandlerMap<ServerMessageType>;
53
+
54
+ export function dispatchServerMessage(msg: ServerMessage, context: DispatchContext): void {
55
+ // The mapped type preserves the message/handler correlation. TypeScript
56
+ // loses that correlation at an indexed union access, so restore it only at
57
+ // this single composition boundary.
58
+ serverMessageHandlers[msg.type](msg as never, context);
59
+ }
@@ -0,0 +1,17 @@
1
+ import { useMaskStore } from "../../stores/mask-store.js";
2
+ import type { ServerMessageHandlerMap } from "./types.js";
3
+
4
+ type MaskFrame = "mask_request";
5
+
6
+ export const maskFrameHandlers = {
7
+ mask_request: (msg) => {
8
+ // Opens the editor; the answer travels back from the component, because
9
+ // only the user can say which part of the picture they meant.
10
+ useMaskStore.getState().open({
11
+ requestId: msg.requestId,
12
+ imagePath: msg.imagePath,
13
+ ...(msg.instruction ? { instruction: msg.instruction } : {}),
14
+ ...(msg.turnId ? { turnId: msg.turnId } : {}),
15
+ });
16
+ },
17
+ } satisfies ServerMessageHandlerMap<MaskFrame>;
@@ -0,0 +1,35 @@
1
+ import { useChatStore } from "../../stores/chat-store.js";
2
+ import { useProviderStore } from "../../stores/provider-store.js";
3
+ import type { ServerMessageHandlerMap } from "./types.js";
4
+
5
+ type ProviderFrame = "session_info";
6
+
7
+ export const providerFrameHandlers = {
8
+ session_info: (msg, context) => {
9
+ context.ensureActivitySubscription(msg.sessionId);
10
+ // Record backend ownership for renderer scoping — for ANY session, since
11
+ // background sessions keep their own transcript buffers. Older servers
12
+ // omit backendId; derive it from the pinned profile when still possible
13
+ // (fails only for since-hidden profiles, which then use the default).
14
+ const ownerBackendId =
15
+ msg.backendId ??
16
+ useProviderStore
17
+ .getState()
18
+ .available.find((p) => p.id === msg.providerId)?.backendId;
19
+ if (ownerBackendId) {
20
+ useChatStore.getState().setSessionBackend(msg.sessionId, ownerBackendId);
21
+ }
22
+ // bindDraftSession in the dispatcher handled draft adoption; an info frame
23
+ // may still re-pin the provider picker when it concerns the session in view.
24
+ const current = useChatStore.getState();
25
+ if (context.frameSessionId && context.frameSessionId === current.activeSessionId) {
26
+ useProviderStore.getState().setPinned(msg.providerId ?? null);
27
+ // Reattachment on a fresh connection: enter streaming mode so deltas
28
+ // append to a live assistant message instead of vanishing.
29
+ const chat = current.buffers[context.frameSessionId];
30
+ if (chat && !chat.isStreaming && chat.messages.length === 0) {
31
+ current.startAssistantMessage(context.frameSessionId);
32
+ }
33
+ }
34
+ },
35
+ } satisfies ServerMessageHandlerMap<ProviderFrame>;
@@ -0,0 +1,27 @@
1
+ import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
+ import type { ChatKey, SessionChat } from "../../stores/chat-store.js";
3
+ import { useChatStore } from "../../stores/chat-store.js";
4
+
5
+ export type ServerMessageType = ServerMessage["type"];
6
+
7
+ export interface DispatchContext {
8
+ state: ReturnType<typeof useChatStore.getState>;
9
+ frameSessionId: string | undefined;
10
+ key: ChatKey;
11
+ buffer: () => SessionChat | null | undefined;
12
+ enqueueDelta: (key: ChatKey, kind: "text" | "thinking", text: string) => void;
13
+ ensureActivitySubscription: (sessionId: string | null | undefined) => void;
14
+ requestBrowserLocation: (
15
+ msg: Extract<ServerMessage, { type: "location_request" }>
16
+ ) => void;
17
+ resyncIfNeeded: (sessionId: string | null) => void;
18
+ coldResumeIfNeeded: (sessionId: string | null, messageCount: number) => void;
19
+ markHistoryReplaced: (key: ChatKey) => void;
20
+ }
21
+
22
+ export type ServerMessageHandlerMap<T extends ServerMessageType> = {
23
+ [K in T]: (
24
+ msg: Extract<ServerMessage, { type: K }>,
25
+ context: DispatchContext
26
+ ) => void;
27
+ };
@@ -0,0 +1,164 @@
1
+ import type { ActivitySpan, ActivitySpanEvent } from "@schlessera/brain-ui-sdk/protocol";
2
+ import type { ActivityState, MirrorMaps } from "./activity-store-types.js";
3
+
4
+ /** Bound on the mirror: how many TERMINAL runs stay held. Live runs never count. */
5
+ const MAX_TERMINAL_RUNS = 50;
6
+
7
+ export function rootSpanOf(byId: Record<string, ActivitySpan>): ActivitySpan | undefined {
8
+ return Object.values(byId).find((span) => span.parentSpanId === undefined);
9
+ }
10
+
11
+ /** Drop a whole run from the mirror — spans, events, high-water, reverse index. */
12
+ export function evictRun(maps: MirrorMaps, runId: string): void {
13
+ for (const spanId of Object.keys(maps.spans[runId] ?? {})) {
14
+ delete maps.events[spanId];
15
+ delete maps.spanRun[spanId];
16
+ }
17
+ delete maps.spans[runId];
18
+ delete maps.highWater[runId];
19
+ delete maps.deltaSeq[runId];
20
+ }
21
+
22
+ /**
23
+ * Enforce the terminal-run bound in place. Returns whether anything was
24
+ * evicted. Runs whose root span is still open (or unknown) are never touched.
25
+ */
26
+ export function pruneTerminalRuns(maps: MirrorMaps): boolean {
27
+ const terminal: Array<{ runId: string; startedAt: number }> = [];
28
+ for (const [runId, byId] of Object.entries(maps.spans)) {
29
+ const root = rootSpanOf(byId);
30
+ if (root && root.outcome !== undefined) {
31
+ terminal.push({ runId, startedAt: root.startedAt });
32
+ }
33
+ }
34
+ if (terminal.length <= MAX_TERMINAL_RUNS) return false;
35
+ terminal.sort((a, b) => a.startedAt - b.startedAt);
36
+ for (const { runId } of terminal.slice(0, terminal.length - MAX_TERMINAL_RUNS)) {
37
+ evictRun(maps, runId);
38
+ }
39
+ return true;
40
+ }
41
+
42
+ export function insertEvent(
43
+ list: ActivitySpanEvent[] | undefined,
44
+ event: ActivitySpanEvent
45
+ ): ActivitySpanEvent[] {
46
+ const existing = list ?? [];
47
+ if (existing.some((e) => e.eventIndex === event.eventIndex)) return existing;
48
+ const next = [...existing, event];
49
+ next.sort((a, b) => a.eventIndex - b.eventIndex);
50
+ return next;
51
+ }
52
+
53
+ // --- Selectors (plain functions over the state, like activeChat) ---
54
+
55
+ /** All spans of a run, roots first, then by start time. */
56
+ export function runSpans(state: ActivityState, runId: string): ActivitySpan[] {
57
+ const byId = state.spans[runId];
58
+ if (!byId) return [];
59
+ return Object.values(byId).sort(
60
+ (a, b) =>
61
+ (a.parentSpanId ? 1 : 0) - (b.parentSpanId ? 1 : 0) || a.startedAt - b.startedAt
62
+ );
63
+ }
64
+
65
+ /** Direct children of a span (e.g. the tool calls inside a subagent). */
66
+ export function childSpans(state: ActivityState, parentSpanId: string): ActivitySpan[] {
67
+ const runId = state.spanRun[parentSpanId];
68
+ if (!runId) return [];
69
+ return Object.values(state.spans[runId] ?? {})
70
+ .filter((s) => s.parentSpanId === parentSpanId)
71
+ .sort((a, b) => a.startedAt - b.startedAt);
72
+ }
73
+
74
+ /** The subagent spans spawned by a turn's Agent tool calls, live status included. */
75
+ export function subagentSpans(state: ActivityState, sessionId: string): ActivitySpan[] {
76
+ const out: ActivitySpan[] = [];
77
+ for (const byId of Object.values(state.spans)) {
78
+ for (const span of Object.values(byId)) {
79
+ if (span.kind === "subagent" && span.sessionId === sessionId) out.push(span);
80
+ }
81
+ }
82
+ return out.sort((a, b) => a.startedAt - b.startedAt);
83
+ }
84
+
85
+ /** Stable empty result for `eventsFor` — a fresh `[]` per call would defeat
86
+ * reference-equality checks in zustand selectors. */
87
+ export const EMPTY_EVENTS: ActivitySpanEvent[] = Object.freeze(
88
+ [] as ActivitySpanEvent[]
89
+ ) as ActivitySpanEvent[];
90
+
91
+ /** Ordered events of a span; the shared frozen empty array when none. */
92
+ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
93
+ return state.events[spanId] ?? EMPTY_EVENTS;
94
+ }
95
+
96
+ /** Payload event types the tool expander owns — every OTHER type belongs to
97
+ * the narrative stream (`narrativeEventsFor`) so nothing recorded is
98
+ * rendered nowhere. */
99
+ const TOOL_PAYLOAD_TYPES = new Set(["tool_input", "tool_output"]);
100
+
101
+ /**
102
+ * The recorded input/output payload events of a tool span (AE7). Returns a
103
+ * fresh array per call — subscribe through `useShallow` (like `childSpans`).
104
+ */
105
+ export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
106
+ const events = state.events[spanId];
107
+ if (!events) return EMPTY_EVENTS;
108
+ const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
109
+ return payloads.length > 0 ? payloads : EMPTY_EVENTS;
110
+ }
111
+
112
+ /**
113
+ * Everything recorded against a span that is NOT a tool input/output payload:
114
+ * transcript excerpts, job output, and any span-sink event type a producer
115
+ * invents. Rendered as the span's narrative so an unknown type degrades to a
116
+ * labelled block rather than to invisibility.
117
+ */
118
+ export function narrativeEventsFor(
119
+ state: ActivityState,
120
+ spanId: string
121
+ ): ActivitySpanEvent[] {
122
+ const events = state.events[spanId];
123
+ if (!events) return EMPTY_EVENTS;
124
+ const rest = events.filter((e) => !TOOL_PAYLOAD_TYPES.has(e.eventType));
125
+ return rest.length > 0 ? rest : EMPTY_EVENTS;
126
+ }
127
+
128
+ /** Every event recorded under a run, ordered by time — the run detail's
129
+ * narrative stream and the raw-trace dump both read through this. */
130
+ export function runEvents(state: ActivityState, runId: string): ActivitySpanEvent[] {
131
+ const byId = state.spans[runId];
132
+ if (!byId) return EMPTY_EVENTS;
133
+ const out: ActivitySpanEvent[] = [];
134
+ for (const spanId of Object.keys(byId)) {
135
+ const events = state.events[spanId];
136
+ if (events) out.push(...events);
137
+ }
138
+ if (out.length === 0) return EMPTY_EVENTS;
139
+ return out.sort((a, b) => a.ts - b.ts || a.eventIndex - b.eventIndex);
140
+ }
141
+
142
+ /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
143
+ export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
144
+ const runId = state.spanRun[toolUseId];
145
+ return runId ? (state.spans[runId]?.[toolUseId] ?? null) : null;
146
+ }
147
+
148
+ /**
149
+ * Server-stamped timing for a tool call. One clock for live and reloaded
150
+ * views: when the activity stream carries the span, its timestamps win over
151
+ * client stamps (AE2 — live and revisit must render identical timings).
152
+ * `waitUntil` marks the approval-wait/execution boundary.
153
+ */
154
+ export function timingFor(
155
+ state: ActivityState,
156
+ toolUseId: string
157
+ ): { startedAt: number; endedAt?: number } | null {
158
+ const span = spanForTool(state, toolUseId);
159
+ if (!span) return null;
160
+ return {
161
+ startedAt: span.waitUntil ?? span.startedAt,
162
+ ...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
163
+ };
164
+ }
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ActivitySpan,
3
+ ActivitySpanEvent,
4
+ ServerActivityDelta,
5
+ ServerActivitySnapshot,
6
+ } from "@schlessera/brain-ui-sdk/protocol";
7
+ import type { ActivityIntent } from "../lib/api-client.js";
8
+
9
+ export interface ActivityState {
10
+ /** server_hello said this host records activity. */
11
+ supported: boolean;
12
+ /** Session ids this connection has an activity subscription for. */
13
+ subscribed: Record<string, true>;
14
+ /** Bumped on every new connection so subscribe effects re-send. */
15
+ connectionEpoch: number;
16
+ /** Live span state per run, keyed runId -> spanId. */
17
+ spans: Record<string, Record<string, ActivitySpan>>;
18
+ /** Ordered events per span (already sorted by eventIndex). */
19
+ events: Record<string, ActivitySpanEvent[]>;
20
+ /** Per-run high-water seq from the snapshot; deltas at or below drop. */
21
+ highWater: Record<string, number>;
22
+ /**
23
+ * Newest APPLIED delta seq per run. Kept apart from `highWater` on
24
+ * purpose: the snapshot staleness guard compares against the max of both
25
+ * (a snapshot older than an applied delta must not roll it back), while
26
+ * the delta gate keeps comparing against the snapshot floor alone so
27
+ * out-of-order delta delivery stays tolerated (insert-if-absent absorbs it).
28
+ */
29
+ deltaSeq: Record<string, number>;
30
+ /** spanId -> runId reverse index, for timing lookups by toolUseId. */
31
+ spanRun: Record<string, string>;
32
+
33
+ /** Unacknowledged notification intents — the guaranteed-tier inbox. */
34
+ inbox: ActivityIntent[];
35
+ loadInbox: () => Promise<void>;
36
+ acknowledgeIntent: (id: number) => Promise<void>;
37
+ acknowledgeAllIntents: () => Promise<void>;
38
+ setSupported: (supported: boolean) => void;
39
+ /** New connection: server-side subscriptions are gone; re-subscribe lazily. */
40
+ resetSubscriptions: () => void;
41
+ bumpConnectionEpoch: () => void;
42
+ markSubscribed: (sessionId: string) => void;
43
+ applySnapshot: (msg: ServerActivitySnapshot) => void;
44
+ applyDelta: (msg: ServerActivityDelta) => void;
45
+ clear: () => void;
46
+ }
47
+
48
+ /** The five indexes that make up the mirror, mutated together during merges. */
49
+ export interface MirrorMaps {
50
+ spans: Record<string, Record<string, ActivitySpan>>;
51
+ events: Record<string, ActivitySpanEvent[]>;
52
+ highWater: Record<string, number>;
53
+ deltaSeq: Record<string, number>;
54
+ spanRun: Record<string, string>;
55
+ }
@@ -1,19 +1,30 @@
1
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";
2
+ import { api } from "../lib/api-client.js";
9
3
  import { registerDevHandle } from "../config.js";
4
+ import type { ActivityState, MirrorMaps } from "./activity-store-types.js";
5
+ import {
6
+ evictRun,
7
+ insertEvent,
8
+ pruneTerminalRuns,
9
+ rootSpanOf,
10
+ } from "./activity-store-helpers.js";
11
+
12
+ export {
13
+ EMPTY_EVENTS,
14
+ childSpans,
15
+ eventsFor,
16
+ narrativeEventsFor,
17
+ payloadEventsFor,
18
+ runEvents,
19
+ runSpans,
20
+ spanForTool,
21
+ subagentSpans,
22
+ timingFor,
23
+ } from "./activity-store-helpers.js";
10
24
 
11
25
  /** How many finished runs' details are fetched when a session opens. */
12
26
  const HISTORY_RUN_LIMIT = 10;
13
27
 
14
- /** Bound on the mirror: how many TERMINAL runs stay held. Live runs never count. */
15
- const MAX_TERMINAL_RUNS = 50;
16
-
17
28
  /**
18
29
  * Client mirror of the server's activity record, fed by the snapshot-then-
19
30
  * delta stream. The server computes; this store only holds and indexes what
@@ -34,45 +45,6 @@ const MAX_TERMINAL_RUNS = 50;
34
45
  * evicted wholesale (spans, events, high-water, reverse index). Live runs
35
46
  * are never evicted; evicted history remains fetchable over REST.
36
47
  */
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
48
  export const useActivityStore = create<ActivityState>((set, get) => ({
77
49
  supported: false,
78
50
  subscribed: {},
@@ -309,174 +281,6 @@ export async function loadSpanPayloads(spanId: string): Promise<void> {
309
281
  }
310
282
  }
311
283
 
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
- /** Payload event types the tool expander owns — every OTHER type belongs to
411
- * the narrative stream (`narrativeEventsFor`) so nothing recorded is
412
- * rendered nowhere. */
413
- const TOOL_PAYLOAD_TYPES = new Set(["tool_input", "tool_output"]);
414
-
415
- /**
416
- * The recorded input/output payload events of a tool span (AE7). Returns a
417
- * fresh array per call — subscribe through `useShallow` (like `childSpans`).
418
- */
419
- export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
420
- const events = state.events[spanId];
421
- if (!events) return EMPTY_EVENTS;
422
- const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
423
- return payloads.length > 0 ? payloads : EMPTY_EVENTS;
424
- }
425
-
426
- /**
427
- * Everything recorded against a span that is NOT a tool input/output payload:
428
- * transcript excerpts, job output, and any span-sink event type a producer
429
- * invents. Rendered as the span's narrative so an unknown type degrades to a
430
- * labelled block rather than to invisibility.
431
- */
432
- export function narrativeEventsFor(
433
- state: ActivityState,
434
- spanId: string
435
- ): ActivitySpanEvent[] {
436
- const events = state.events[spanId];
437
- if (!events) return EMPTY_EVENTS;
438
- const rest = events.filter((e) => !TOOL_PAYLOAD_TYPES.has(e.eventType));
439
- return rest.length > 0 ? rest : EMPTY_EVENTS;
440
- }
441
-
442
- /** Every event recorded under a run, ordered by time — the run detail's
443
- * narrative stream and the raw-trace dump both read through this. */
444
- export function runEvents(state: ActivityState, runId: string): ActivitySpanEvent[] {
445
- const byId = state.spans[runId];
446
- if (!byId) return EMPTY_EVENTS;
447
- const out: ActivitySpanEvent[] = [];
448
- for (const spanId of Object.keys(byId)) {
449
- const events = state.events[spanId];
450
- if (events) out.push(...events);
451
- }
452
- if (out.length === 0) return EMPTY_EVENTS;
453
- return out.sort((a, b) => a.ts - b.ts || a.eventIndex - b.eventIndex);
454
- }
455
-
456
- /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
457
- export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
458
- const runId = state.spanRun[toolUseId];
459
- return runId ? (state.spans[runId]?.[toolUseId] ?? null) : null;
460
- }
461
-
462
- /**
463
- * Server-stamped timing for a tool call. One clock for live and reloaded
464
- * views: when the activity stream carries the span, its timestamps win over
465
- * client stamps (AE2 — live and revisit must render identical timings).
466
- * `waitUntil` marks the approval-wait/execution boundary.
467
- */
468
- export function timingFor(
469
- state: ActivityState,
470
- toolUseId: string
471
- ): { startedAt: number; endedAt?: number } | null {
472
- const span = spanForTool(state, toolUseId);
473
- if (!span) return null;
474
- return {
475
- startedAt: span.waitUntil ?? span.startedAt,
476
- ...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
477
- };
478
- }
479
-
480
284
  registerDevHandle(() => {
481
285
  if (typeof window === "undefined") return;
482
286
  (window as unknown as { __activityStore?: typeof useActivityStore }).__activityStore =
@@ -1,7 +1,7 @@
1
1
  import { create } from "zustand";
2
2
 
3
- type WsStatus = "connecting" | "connected" | "disconnected";
4
- type VpnStatus =
3
+ export type WsStatus = "connecting" | "connected" | "disconnected";
4
+ export type VpnStatus =
5
5
  | "connected"
6
6
  | "forbidden"
7
7
  | "unauthorized"
@@ -27,9 +27,21 @@ export interface ConnectionError {
27
27
  interface ConnectionState {
28
28
  wsStatus: WsStatus;
29
29
  vpnStatus: VpnStatus;
30
+ handshakeFailures: number;
31
+ lastCloseCode: number | null;
32
+ /**
33
+ * Monotonic count of sockets that reached `open`. A probe started before a
34
+ * socket opened compares this to decide whether its answer is still the
35
+ * newest evidence — comparing wsStatus twice cannot see a drop and a
36
+ * replacement open that both land inside one probe.
37
+ */
38
+ socketOpens: number;
30
39
  lastError: ConnectionError | null;
31
40
  setWsStatus: (status: WsStatus) => void;
32
41
  setVpnStatus: (status: VpnStatus) => void;
42
+ recordWsClose: (opened: boolean, code: number) => void;
43
+ /** A socket reached `open`: the refusal evidence so far is spent. */
44
+ noteSocketOpen: () => void;
33
45
  reportError: (code: string, message: string) => void;
34
46
  clearError: () => void;
35
47
  }
@@ -37,9 +49,28 @@ interface ConnectionState {
37
49
  export const useConnectionStore = create<ConnectionState>((set) => ({
38
50
  wsStatus: "disconnected",
39
51
  vpnStatus: "checking",
52
+ handshakeFailures: 0,
53
+ lastCloseCode: null,
54
+ socketOpens: 0,
40
55
  lastError: null,
41
56
  setWsStatus: (wsStatus) => set({ wsStatus }),
42
57
  setVpnStatus: (vpnStatus) => set({ vpnStatus }),
58
+ recordWsClose: (opened, lastCloseCode) =>
59
+ set((state) => ({
60
+ lastCloseCode,
61
+ handshakeFailures: opened
62
+ ? state.handshakeFailures
63
+ : state.handshakeFailures + 1,
64
+ })),
65
+ noteSocketOpen: () =>
66
+ set((state) => ({
67
+ handshakeFailures: 0,
68
+ // Cleared here, not on the next close: the server accepts the upgrade and
69
+ // THEN closes with 4008 when it is at its connection cap, so the code
70
+ // must survive an open to be read, and must not outlive the next one.
71
+ lastCloseCode: null,
72
+ socketOpens: state.socketOpens + 1,
73
+ })),
43
74
  reportError: (code, message) => set({ lastError: { code, message, at: Date.now() } }),
44
75
  clearError: () => set({ lastError: null }),
45
76
  }));