@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
@@ -1,116 +1,23 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
- import { BrainUiClient } from "@schlessera/brain-ui-sdk/client";
2
+ import {
3
+ BrainUiClient,
4
+ type WebSocketClose,
5
+ } from "@schlessera/brain-ui-sdk/client";
3
6
  import { useConnectionStore } from "../stores/connection-store.js";
4
7
  import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
- import { useProviderStore } from "../stores/provider-store.js";
6
- import { useMaskStore } from "../stores/mask-store.js";
7
8
  import { useActivityStore, loadSessionActivityHistory } from "../stores/activity-store.js";
8
9
  import { getWsUrl } from "../lib/backend.js";
9
10
  import type {
10
11
  ServerMessage,
11
12
  ClientMessage,
12
13
  ServerLocationRequest,
13
- SessionHistoryMessage,
14
- AskUserQuestion,
15
14
  } from "@schlessera/brain-ui-sdk/protocol";
16
- import type { ChatMessage, ToolCall, AskUserExchange } from "../stores/chat-store.js";
17
- import { isAskUserTool } from "../lib/tool-names.js";
15
+ import { dispatchServerMessage } from "./websocket-handlers/index.js";
16
+ import { runStateForFrame } from "./websocket-handlers/chat.js";
17
+ import { recheckVpnStatus } from "./use-vpn-status.js";
18
+ import { REFUSAL_ATTEMPTS } from "../components/connectivity/connection-state.js";
18
19
 
19
- let historyMessageCounter = 0;
20
- function historyId() {
21
- return `hist-${++historyMessageCounter}-${Date.now()}`;
22
- }
23
-
24
- function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
25
- // Fallback for history without chronological parts: synthesize the legacy
26
- // grouped order (thinking, tools, text).
27
- const parts =
28
- msg.parts ??
29
- [
30
- ...(msg.thinking ? [{ kind: "thinking", text: msg.thinking } as const] : []),
31
- ...msg.toolCalls.map((_, i) => ({ kind: "tool", toolIndex: i }) as const),
32
- ...(msg.content ? [{ kind: "text", text: msg.content } as const] : []),
33
- ];
34
- const askUserExchanges = reconstructAskUserExchanges(msg.toolCalls);
35
- return {
36
- id: historyId(),
37
- role: msg.role,
38
- content: msg.content,
39
- thinking: msg.thinking,
40
- toolCalls: msg.toolCalls.map((tc): ToolCall => ({
41
- id: tc.id,
42
- name: tc.name,
43
- input: tc.input,
44
- inputJson: JSON.stringify(tc.input, null, 2),
45
- output: tc.output,
46
- isError: tc.isError,
47
- status: "complete",
48
- })),
49
- parts,
50
- isStreaming: false,
51
- timestamp: Date.now(),
52
- ...(askUserExchanges ? { askUserExchanges } : {}),
53
- ...(msg.attachmentCount ? { attachmentCount: msg.attachmentCount } : {}),
54
- };
55
- }
56
-
57
- /**
58
- * Rebuild ask_user exchanges from a resumed message's tool calls. The ask_user
59
- * tool's input carries the questions and its output carries the JSON answer
60
- * payload, so an answered question survives session resume (rendered in its
61
- * chronological slot, collapsed) instead of vanishing.
62
- */
63
- function isBareAnswersMap(v: unknown): v is Record<string, string> {
64
- return (
65
- !!v &&
66
- typeof v === "object" &&
67
- !Array.isArray(v) &&
68
- Object.values(v).every((x) => typeof x === "string")
69
- );
70
- }
71
-
72
- function reconstructAskUserExchanges(
73
- toolCalls: SessionHistoryMessage["toolCalls"]
74
- ): AskUserExchange[] | undefined {
75
- const exchanges: AskUserExchange[] = [];
76
- for (const tc of toolCalls) {
77
- if (!isAskUserTool(tc.name)) continue;
78
- const questions = (tc.input?.questions as AskUserQuestion[]) ?? [];
79
- const exchange: AskUserExchange = { requestId: tc.id, questions };
80
- if (tc.output) {
81
- try {
82
- const payload = JSON.parse(tc.output) as {
83
- answers?: Record<string, string>;
84
- annotations?: AskUserExchange["annotations"];
85
- };
86
- if (payload && typeof payload === "object" && payload.answers) {
87
- exchange.answers = payload.answers;
88
- exchange.annotations = payload.annotations;
89
- } else if (isBareAnswersMap(payload)) {
90
- // The pi backend persists the bare answers map, without the
91
- // `{answers}` envelope the Claude tool writes.
92
- exchange.answers = payload;
93
- }
94
- } catch {
95
- // Non-JSON output means the question was dismissed or errored out.
96
- exchange.cancelled = true;
97
- }
98
- }
99
- if (tc.isError && !exchange.answers) exchange.cancelled = true;
100
- exchanges.push(exchange);
101
- }
102
- return exchanges.length ? exchanges : undefined;
103
- }
104
-
105
- /** Map a frame to the run-state its session should show in the session list. */
106
- export function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "idle" {
107
- if (msg.type === "result" || msg.type === "error") return "idle";
108
- if (msg.type === "status") {
109
- if (msg.status === "queued") return "queued";
110
- if (msg.status === "idle" || msg.status === "cancelled") return "idle";
111
- }
112
- return "streaming";
113
- }
20
+ export { runStateForFrame } from "./websocket-handlers/chat.js";
114
21
 
115
22
  /**
116
23
  * Does this frame announce the identity of the conversation THIS client just
@@ -226,21 +133,24 @@ export function handleServerMessage(msg: ServerMessage) {
226
133
  const state = useChatStore.getState();
227
134
 
228
135
  // Activity stream frames feed their own store and never touch chat state.
229
- if (msg.type === "server_hello") {
230
- useActivityStore.getState().setSupported(msg.capabilities?.activity === true);
231
- // A new hello means a new connection: server-side subscriptions are gone.
232
- useActivityStore.getState().resetSubscriptions();
233
- // …and view-owned subscriptions (the Activity index) must re-send too.
234
- useActivityStore.getState().bumpConnectionEpoch();
235
- ensureActivitySubscription(state.activeSessionId);
236
- return;
237
- }
238
- if (msg.type === "activity_snapshot") {
239
- useActivityStore.getState().applySnapshot(msg);
240
- return;
241
- }
242
- if (msg.type === "activity_delta") {
243
- useActivityStore.getState().applyDelta(msg);
136
+ if (
137
+ msg.type === "server_hello" ||
138
+ msg.type === "activity_snapshot" ||
139
+ msg.type === "activity_delta"
140
+ ) {
141
+ const key = state.activeSessionId;
142
+ dispatchServerMessage(msg, {
143
+ state,
144
+ frameSessionId: undefined,
145
+ key,
146
+ buffer: () => (key === null ? state.draft : state.buffers[key]),
147
+ enqueueDelta,
148
+ ensureActivitySubscription,
149
+ requestBrowserLocation,
150
+ resyncIfNeeded,
151
+ coldResumeIfNeeded,
152
+ markHistoryReplaced,
153
+ });
244
154
  return;
245
155
  }
246
156
 
@@ -290,162 +200,18 @@ export function handleServerMessage(msg: ServerMessage) {
290
200
  return key === null ? s.draft : s.buffers[key];
291
201
  };
292
202
 
293
- switch (msg.type) {
294
- case "text_delta":
295
- // Opening the bubble stays immediate — the first token should show a
296
- // message starting, and every later delta needs isStreaming to be true.
297
- if (!buffer()?.isStreaming) {
298
- state.startAssistantMessage(key);
299
- }
300
- enqueueDelta(key, "text", msg.text);
301
- break;
302
-
303
- case "thinking_delta":
304
- if (!buffer()?.isStreaming) {
305
- state.startAssistantMessage(key);
306
- }
307
- enqueueDelta(key, "thinking", msg.text);
308
- break;
309
-
310
- case "tool_use_start":
311
- // A tool call INSIDE a subagent nests under its Agent entry, rendered
312
- // from the activity stream — putting it in the flat transcript would
313
- // interleave fan-out work with the main turn's steps.
314
- if (msg.parentToolUseId) break;
315
- state.startToolCall(key, msg.toolUseId, msg.toolName);
316
- break;
317
-
318
- case "tool_input_delta":
319
- state.appendToolInput(key, msg.partialJson);
320
- break;
321
-
322
- case "tool_use_complete":
323
- // Subagent tool calls render nested (activity stream), and the flat
324
- // store's name-based fallback match must never see them.
325
- if (msg.parentToolUseId) break;
326
- state.completeToolCall(key, msg.toolUseId, msg.toolName, msg.input);
327
- break;
328
-
329
- case "tool_approval_request":
330
- state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description, msg.kind);
331
- break;
332
-
333
- case "tool_result": {
334
- state.setToolResult(key, msg.toolUseId, msg.output, msg.isError);
335
- // Once the agent receives the ask_user tool result, the exchange is
336
- // complete — drop any leftover ask_user UI state.
337
- const chat = buffer();
338
- if (chat?.askUser?.answers || chat?.askUser?.cancelled) {
339
- state.clearAskUser(key);
340
- }
341
- break;
342
- }
343
-
344
- case "ask_user_request":
345
- state.setAskUserRequest(key, msg.requestId, msg.questions);
346
- break;
347
-
348
- case "location_request":
349
- requestBrowserLocation(msg);
350
- break;
351
-
352
- case "mask_request":
353
- // Opens the editor; the answer travels back from the component, because
354
- // only the user can say which part of the picture they meant.
355
- useMaskStore.getState().open({
356
- requestId: msg.requestId,
357
- imagePath: msg.imagePath,
358
- ...(msg.instruction ? { instruction: msg.instruction } : {}),
359
- ...(msg.turnId ? { turnId: msg.turnId } : {}),
360
- });
361
- break;
362
-
363
- case "result":
364
- state.finishAssistantMessage(key);
365
- resyncIfNeeded(msg.sessionId);
366
- break;
367
-
368
- case "session_info": {
369
- ensureActivitySubscription(msg.sessionId);
370
- // Record backend ownership for renderer scoping — for ANY session, since
371
- // background sessions keep their own transcript buffers. Older servers
372
- // omit backendId; derive it from the pinned profile when still possible
373
- // (fails only for since-hidden profiles, which then use the default).
374
- const ownerBackendId =
375
- msg.backendId ??
376
- useProviderStore
377
- .getState()
378
- .available.find((p) => p.id === msg.providerId)?.backendId;
379
- if (ownerBackendId) {
380
- useChatStore.getState().setSessionBackend(msg.sessionId, ownerBackendId);
381
- }
382
- // bindDraftSession above handled draft adoption; an info frame may still
383
- // re-pin the provider picker when it concerns the session in view.
384
- const current = useChatStore.getState();
385
- if (frameSessionId && frameSessionId === current.activeSessionId) {
386
- useProviderStore.getState().setPinned(msg.providerId ?? null);
387
- // Reattachment on a fresh connection: enter streaming mode so deltas
388
- // append to a live assistant message instead of vanishing.
389
- const chat = current.buffers[frameSessionId];
390
- if (chat && !chat.isStreaming && chat.messages.length === 0) {
391
- current.startAssistantMessage(frameSessionId);
392
- }
393
- }
394
- break;
395
- }
396
-
397
- case "session_history": {
398
- const converted = msg.messages.map(convertHistoryMessage);
399
- if (msg.append) {
400
- state.appendMessages(key, converted);
401
- } else {
402
- // First (replacing) chunk = fresh authoritative transcript. Cancel any
403
- // pending reconnect resync so we don't redundantly re-fetch it, and
404
- // mark this session cold-resumed so idle-status handling doesn't loop.
405
- state.setMessages(key, converted);
406
- if (key !== null && key === resyncSessionId) resyncSessionId = null;
407
- coldResumedSessionId = key;
408
- }
409
- break;
410
- }
411
-
412
- case "status": {
413
- // Only finish streaming if this buffer actually started it
414
- if (
415
- (msg.status === "idle" || msg.status === "cancelled") &&
416
- buffer()?.isStreaming
417
- ) {
418
- state.finishAssistantMessage(key);
419
- }
420
- // A session that finished while we were offline never delivers its
421
- // `result` — the idle status on reconnect is the cue to resync. Keyed to
422
- // THIS frame's session: a background session settling must not consume
423
- // (or trigger) the active session's pending resync.
424
- if (msg.status === "idle" || msg.status === "cancelled") {
425
- const current = useChatStore.getState();
426
- resyncIfNeeded(frameSessionId ?? current.activeSessionId);
427
- // Cold load: we hold a stored session id but an empty transcript and
428
- // the server is idle (so it sent no snapshot). Fetch the history.
429
- // Only the active session's own (or an unscoped legacy) idle counts.
430
- if (!frameSessionId || frameSessionId === current.activeSessionId) {
431
- coldResumeIfNeeded(current.activeSessionId, activeChat(current).messages.length);
432
- }
433
- }
434
- break;
435
- }
436
-
437
- case "error":
438
- // ALWAYS recorded. Appending to the transcript only works while a
439
- // message is streaming, and this used to be the whole handler — so an
440
- // error arriving between turns (a rejected frame, a failed resume) was
441
- // dropped as silently on the client as it was on the server.
442
- useConnectionStore.getState().reportError(msg.code, msg.message);
443
- if (buffer()?.isStreaming) {
444
- state.appendText(key, `\n\n**Error:** ${msg.message}`);
445
- state.finishAssistantMessage(key);
446
- }
447
- break;
448
- }
203
+ dispatchServerMessage(msg, {
204
+ state,
205
+ frameSessionId,
206
+ key,
207
+ buffer,
208
+ enqueueDelta,
209
+ ensureActivitySubscription,
210
+ requestBrowserLocation,
211
+ resyncIfNeeded,
212
+ coldResumeIfNeeded,
213
+ markHistoryReplaced,
214
+ });
449
215
  }
450
216
 
451
217
  /**
@@ -509,6 +275,17 @@ function requestBrowserLocation(msg: ServerLocationRequest) {
509
275
  let wasDisconnected = false;
510
276
  let resyncSessionId: string | null = null;
511
277
 
278
+ function handleSocketClose(close: WebSocketClose): void {
279
+ const connection = useConnectionStore.getState();
280
+ connection.recordWsClose(close.opened, close.code);
281
+ if (
282
+ !close.opened &&
283
+ useConnectionStore.getState().handshakeFailures >= REFUSAL_ATTEMPTS
284
+ ) {
285
+ recheckVpnStatus();
286
+ }
287
+ }
288
+
512
289
  function resyncIfNeeded(sessionId: string | null) {
513
290
  if (!resyncSessionId || !sessionId || sessionId !== resyncSessionId) return;
514
291
  resyncSessionId = null;
@@ -524,6 +301,11 @@ function resyncIfNeeded(sessionId: string | null) {
524
301
  // status and loop.
525
302
  let coldResumedSessionId: string | null = null;
526
303
 
304
+ function markHistoryReplaced(key: ChatKey): void {
305
+ if (key !== null && key === resyncSessionId) resyncSessionId = null;
306
+ coldResumedSessionId = key;
307
+ }
308
+
527
309
  function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
528
310
  if (!sessionId || messageCount > 0) return;
529
311
  if (coldResumedSessionId === sessionId) return;
@@ -534,6 +316,10 @@ function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
534
316
  function handleStatusChange(status: "connecting" | "connected" | "disconnected") {
535
317
  useConnectionStore.getState().setWsStatus(status);
536
318
 
319
+ if (status === "connected") {
320
+ useConnectionStore.getState().noteSocketOpen();
321
+ }
322
+
537
323
  // A status change can be followed by a history replay that rewrites the
538
324
  // buffer, so land whatever is still buffered before anything reads it. In a
539
325
  // hidden tab requestAnimationFrame does not run at all, which is exactly the
@@ -581,12 +367,19 @@ export function sendClientMessage(msg: ClientMessage): boolean {
581
367
  return true;
582
368
  }
583
369
 
370
+ /** Retry the singleton socket immediately, skipping any remaining backoff. */
371
+ export function reconnectWebSocketNow(): void {
372
+ wsClient?.reconnectNow();
373
+ }
374
+
584
375
  export function useWebSocket() {
585
376
  const initialized = useRef(false);
586
377
 
587
378
  useEffect(() => {
588
379
  if (initialized.current) return;
589
380
  initialized.current = true;
381
+ let disposed = false;
382
+ useConnectionStore.getState().noteSocketOpen();
590
383
 
591
384
  wsClient = new BrainUiClient({
592
385
  url: getWsUrl(),
@@ -594,6 +387,9 @@ export function useWebSocket() {
594
387
  // session-buffer demux — see handleServerMessage.
595
388
  handlers: { onAny: handleServerMessage },
596
389
  onStatusChange: handleStatusChange,
390
+ onClose: (close) => {
391
+ if (!disposed) handleSocketClose(close);
392
+ },
597
393
  // A frame the SDK refused. Surfaced rather than logged into a console
598
394
  // nobody is attached to; the server-side counterpart is the
599
395
  // ws.frames.dropped counter.
@@ -619,6 +415,7 @@ export function useWebSocket() {
619
415
  document.addEventListener("visibilitychange", onVisible);
620
416
 
621
417
  return () => {
418
+ disposed = true;
622
419
  window.removeEventListener("online", reconnectNow);
623
420
  document.removeEventListener("visibilitychange", onVisible);
624
421
  wsClient?.close();
@@ -0,0 +1,13 @@
1
+ import { useActivityStore } from "../../stores/activity-store.js";
2
+ import type { ServerMessageHandlerMap } from "./types.js";
3
+
4
+ type ActivityFrame = "activity_snapshot" | "activity_delta";
5
+
6
+ export const activityFrameHandlers = {
7
+ activity_snapshot: (msg) => {
8
+ useActivityStore.getState().applySnapshot(msg);
9
+ },
10
+ activity_delta: (msg) => {
11
+ useActivityStore.getState().applyDelta(msg);
12
+ },
13
+ } satisfies ServerMessageHandlerMap<ActivityFrame>;
@@ -0,0 +1,210 @@
1
+ import { activeChat, useChatStore } from "../../stores/chat-store.js";
2
+ import type { ChatMessage, ToolCall, AskUserExchange } from "../../stores/chat-store.js";
3
+ import type {
4
+ AskUserQuestion,
5
+ ServerMessage,
6
+ SessionHistoryMessage,
7
+ } from "@schlessera/brain-ui-sdk/protocol";
8
+ import { isAskUserTool } from "../../lib/tool-names.js";
9
+ import type { ServerMessageHandlerMap } from "./types.js";
10
+
11
+ type ChatFrame =
12
+ | "text_delta"
13
+ | "thinking_delta"
14
+ | "tool_use_start"
15
+ | "tool_input_delta"
16
+ | "tool_use_complete"
17
+ | "tool_approval_request"
18
+ | "tool_result"
19
+ | "ask_user_request"
20
+ | "result"
21
+ | "session_history"
22
+ | "status";
23
+
24
+ let historyMessageCounter = 0;
25
+ function historyId() {
26
+ return `hist-${++historyMessageCounter}-${Date.now()}`;
27
+ }
28
+
29
+ function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
30
+ // Fallback for history without chronological parts: synthesize the legacy
31
+ // grouped order (thinking, tools, text).
32
+ const parts =
33
+ msg.parts ??
34
+ [
35
+ ...(msg.thinking ? [{ kind: "thinking", text: msg.thinking } as const] : []),
36
+ ...msg.toolCalls.map((_, i) => ({ kind: "tool", toolIndex: i }) as const),
37
+ ...(msg.content ? [{ kind: "text", text: msg.content } as const] : []),
38
+ ];
39
+ const askUserExchanges = reconstructAskUserExchanges(msg.toolCalls);
40
+ return {
41
+ id: historyId(),
42
+ role: msg.role,
43
+ content: msg.content,
44
+ thinking: msg.thinking,
45
+ toolCalls: msg.toolCalls.map((tc): ToolCall => ({
46
+ id: tc.id,
47
+ name: tc.name,
48
+ input: tc.input,
49
+ inputJson: JSON.stringify(tc.input, null, 2),
50
+ output: tc.output,
51
+ isError: tc.isError,
52
+ status: "complete",
53
+ })),
54
+ parts,
55
+ isStreaming: false,
56
+ timestamp: Date.now(),
57
+ ...(askUserExchanges ? { askUserExchanges } : {}),
58
+ ...(msg.attachmentCount ? { attachmentCount: msg.attachmentCount } : {}),
59
+ };
60
+ }
61
+
62
+ /** Rebuild ask_user exchanges from a resumed message's tool calls. */
63
+ function isBareAnswersMap(v: unknown): v is Record<string, string> {
64
+ return (
65
+ !!v &&
66
+ typeof v === "object" &&
67
+ !Array.isArray(v) &&
68
+ Object.values(v).every((x) => typeof x === "string")
69
+ );
70
+ }
71
+
72
+ function reconstructAskUserExchanges(
73
+ toolCalls: SessionHistoryMessage["toolCalls"]
74
+ ): AskUserExchange[] | undefined {
75
+ const exchanges: AskUserExchange[] = [];
76
+ for (const tc of toolCalls) {
77
+ if (!isAskUserTool(tc.name)) continue;
78
+ const questions = (tc.input?.questions as AskUserQuestion[]) ?? [];
79
+ const exchange: AskUserExchange = { requestId: tc.id, questions };
80
+ if (tc.output) {
81
+ try {
82
+ const payload = JSON.parse(tc.output) as {
83
+ answers?: Record<string, string>;
84
+ annotations?: AskUserExchange["annotations"];
85
+ };
86
+ if (payload && typeof payload === "object" && payload.answers) {
87
+ exchange.answers = payload.answers;
88
+ exchange.annotations = payload.annotations;
89
+ } else if (isBareAnswersMap(payload)) {
90
+ // The pi backend persists the bare answers map, without the
91
+ // `{answers}` envelope the Claude tool writes.
92
+ exchange.answers = payload;
93
+ }
94
+ } catch {
95
+ // Non-JSON output means the question was dismissed or errored out.
96
+ exchange.cancelled = true;
97
+ }
98
+ }
99
+ if (tc.isError && !exchange.answers) exchange.cancelled = true;
100
+ exchanges.push(exchange);
101
+ }
102
+ return exchanges.length ? exchanges : undefined;
103
+ }
104
+
105
+ /** Map a frame to the run-state its session should show in the session list. */
106
+ export function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "idle" {
107
+ if (msg.type === "result" || msg.type === "error") return "idle";
108
+ if (msg.type === "status") {
109
+ if (msg.status === "queued") return "queued";
110
+ if (msg.status === "idle" || msg.status === "cancelled") return "idle";
111
+ }
112
+ return "streaming";
113
+ }
114
+
115
+ export const chatFrameHandlers = {
116
+ text_delta: (msg, context) => {
117
+ // Opening the bubble stays immediate — the first token should show a
118
+ // message starting, and every later delta needs isStreaming to be true.
119
+ if (!context.buffer()?.isStreaming) {
120
+ context.state.startAssistantMessage(context.key);
121
+ }
122
+ context.enqueueDelta(context.key, "text", msg.text);
123
+ },
124
+ thinking_delta: (msg, context) => {
125
+ if (!context.buffer()?.isStreaming) {
126
+ context.state.startAssistantMessage(context.key);
127
+ }
128
+ context.enqueueDelta(context.key, "thinking", msg.text);
129
+ },
130
+ tool_use_start: (msg, context) => {
131
+ // A tool call INSIDE a subagent nests under its Agent entry, rendered
132
+ // from the activity stream — putting it in the flat transcript would
133
+ // interleave fan-out work with the main turn's steps.
134
+ if (msg.parentToolUseId) return;
135
+ context.state.startToolCall(context.key, msg.toolUseId, msg.toolName);
136
+ },
137
+ tool_input_delta: (msg, context) => {
138
+ context.state.appendToolInput(context.key, msg.partialJson);
139
+ },
140
+ tool_use_complete: (msg, context) => {
141
+ // Subagent tool calls render nested (activity stream), and the flat
142
+ // store's name-based fallback match must never see them.
143
+ if (msg.parentToolUseId) return;
144
+ context.state.completeToolCall(context.key, msg.toolUseId, msg.toolName, msg.input);
145
+ },
146
+ tool_approval_request: (msg, context) => {
147
+ context.state.requestToolApproval(
148
+ context.key,
149
+ msg.toolUseId,
150
+ msg.toolName,
151
+ msg.input,
152
+ msg.description,
153
+ msg.kind
154
+ );
155
+ },
156
+ tool_result: (msg, context) => {
157
+ context.state.setToolResult(context.key, msg.toolUseId, msg.output, msg.isError);
158
+ // Once the agent receives the ask_user tool result, the exchange is
159
+ // complete — drop any leftover ask_user UI state.
160
+ const chat = context.buffer();
161
+ if (chat?.askUser?.answers || chat?.askUser?.cancelled) {
162
+ context.state.clearAskUser(context.key);
163
+ }
164
+ },
165
+ ask_user_request: (msg, context) => {
166
+ context.state.setAskUserRequest(context.key, msg.requestId, msg.questions);
167
+ },
168
+ result: (msg, context) => {
169
+ context.state.finishAssistantMessage(context.key);
170
+ context.resyncIfNeeded(msg.sessionId);
171
+ },
172
+ session_history: (msg, context) => {
173
+ const converted = msg.messages.map(convertHistoryMessage);
174
+ if (msg.append) {
175
+ context.state.appendMessages(context.key, converted);
176
+ } else {
177
+ // First (replacing) chunk = fresh authoritative transcript. Cancel any
178
+ // pending reconnect resync so we don't redundantly re-fetch it, and
179
+ // mark this session cold-resumed so idle-status handling doesn't loop.
180
+ context.state.setMessages(context.key, converted);
181
+ context.markHistoryReplaced(context.key);
182
+ }
183
+ },
184
+ status: (msg, context) => {
185
+ // Only finish streaming if this buffer actually started it
186
+ if (
187
+ (msg.status === "idle" || msg.status === "cancelled") &&
188
+ context.buffer()?.isStreaming
189
+ ) {
190
+ context.state.finishAssistantMessage(context.key);
191
+ }
192
+ // A session that finished while we were offline never delivers its
193
+ // `result` — the idle status on reconnect is the cue to resync. Keyed to
194
+ // THIS frame's session: a background session settling must not consume
195
+ // (or trigger) the active session's pending resync.
196
+ if (msg.status === "idle" || msg.status === "cancelled") {
197
+ const current = useChatStore.getState();
198
+ context.resyncIfNeeded(context.frameSessionId ?? current.activeSessionId);
199
+ // Cold load: we hold a stored session id but an empty transcript and
200
+ // the server is idle (so it sent no snapshot). Fetch the history.
201
+ // Only the active session's own (or an unscoped legacy) idle counts.
202
+ if (!context.frameSessionId || context.frameSessionId === current.activeSessionId) {
203
+ context.coldResumeIfNeeded(
204
+ current.activeSessionId,
205
+ activeChat(current).messages.length
206
+ );
207
+ }
208
+ }
209
+ },
210
+ } satisfies ServerMessageHandlerMap<ChatFrame>;
@@ -0,0 +1,30 @@
1
+ import { useActivityStore } from "../../stores/activity-store.js";
2
+ import { useConnectionStore } from "../../stores/connection-store.js";
3
+ import type { ServerMessageHandlerMap } from "./types.js";
4
+
5
+ type ConnectionFrame = "server_hello" | "location_request" | "error";
6
+
7
+ export const connectionFrameHandlers = {
8
+ server_hello: (msg, context) => {
9
+ useActivityStore.getState().setSupported(msg.capabilities?.activity === true);
10
+ // A new hello means a new connection: server-side subscriptions are gone.
11
+ useActivityStore.getState().resetSubscriptions();
12
+ // …and view-owned subscriptions (the Activity index) must re-send too.
13
+ useActivityStore.getState().bumpConnectionEpoch();
14
+ context.ensureActivitySubscription(context.state.activeSessionId);
15
+ },
16
+ location_request: (msg, context) => {
17
+ context.requestBrowserLocation(msg);
18
+ },
19
+ error: (msg, context) => {
20
+ // ALWAYS recorded. Appending to the transcript only works while a
21
+ // message is streaming, and this used to be the whole handler — so an
22
+ // error arriving between turns (a rejected frame, a failed resume) was
23
+ // dropped as silently on the client as it was on the server.
24
+ useConnectionStore.getState().reportError(msg.code, msg.message);
25
+ if (context.buffer()?.isStreaming) {
26
+ context.state.appendText(context.key, `\n\n**Error:** ${msg.message}`);
27
+ context.state.finishAssistantMessage(context.key);
28
+ }
29
+ },
30
+ } satisfies ServerMessageHandlerMap<ConnectionFrame>;