@schlessera/brain-ui-react 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +12 -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/session-drawer.d.ts.map +1 -1
  41. package/dist/components/chat/session-drawer.js +25 -10
  42. package/dist/components/chat/session-drawer.js.map +1 -1
  43. package/dist/components/chat/tool-views.d.ts +2 -2
  44. package/dist/components/chat/tool-views.d.ts.map +1 -1
  45. package/dist/components/chat/tool-views.js +2 -16
  46. package/dist/components/chat/tool-views.js.map +1 -1
  47. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  48. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  49. package/dist/components/chat/use-markdown-highlight.js +56 -0
  50. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  51. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  52. package/dist/components/connectivity/connection-gate.js +94 -8
  53. package/dist/components/connectivity/connection-gate.js.map +1 -1
  54. package/dist/components/connectivity/connection-state.d.ts +12 -0
  55. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  56. package/dist/components/connectivity/connection-state.js +23 -0
  57. package/dist/components/connectivity/connection-state.js.map +1 -0
  58. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  59. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  60. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  61. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  62. package/dist/components/graph/graph-controls.js +4 -1
  63. package/dist/components/graph/graph-controls.js.map +1 -1
  64. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  65. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  66. package/dist/components/graph/graph-maintenance.js +115 -0
  67. package/dist/components/graph/graph-maintenance.js.map +1 -0
  68. package/dist/components/graph/graph-page.d.ts.map +1 -1
  69. package/dist/components/graph/graph-page.js +11 -306
  70. package/dist/components/graph/graph-page.js.map +1 -1
  71. package/dist/components/graph/graph-scene.d.ts +8 -0
  72. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  73. package/dist/components/graph/graph-scene.js +208 -0
  74. package/dist/components/graph/graph-scene.js.map +1 -0
  75. package/dist/components/graph/graph-spinner.d.ts +2 -0
  76. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  77. package/dist/components/graph/graph-spinner.js +6 -0
  78. package/dist/components/graph/graph-spinner.js.map +1 -0
  79. package/dist/components/layout/app-shell.d.ts.map +1 -1
  80. package/dist/components/layout/app-shell.js +2 -1
  81. package/dist/components/layout/app-shell.js.map +1 -1
  82. package/dist/components/quick-actions/add-modal.d.ts.map +1 -1
  83. package/dist/components/quick-actions/add-modal.js +47 -4
  84. package/dist/components/quick-actions/add-modal.js.map +1 -1
  85. package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
  86. package/dist/components/quick-actions/search-modal.js +46 -18
  87. package/dist/components/quick-actions/search-modal.js.map +1 -1
  88. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
  89. package/dist/components/quick-actions/streaming-modal.js +4 -1
  90. package/dist/components/quick-actions/streaming-modal.js.map +1 -1
  91. package/dist/components/settings/devices-agents-tab.d.ts +5 -0
  92. package/dist/components/settings/devices-agents-tab.d.ts.map +1 -0
  93. package/dist/components/settings/devices-agents-tab.js +96 -0
  94. package/dist/components/settings/devices-agents-tab.js.map +1 -0
  95. package/dist/components/settings/one-time-agent-credential.d.ts +3 -0
  96. package/dist/components/settings/one-time-agent-credential.d.ts.map +1 -0
  97. package/dist/components/settings/one-time-agent-credential.js +55 -0
  98. package/dist/components/settings/one-time-agent-credential.js.map +1 -0
  99. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  100. package/dist/components/settings/settings-panel.js +16 -3
  101. package/dist/components/settings/settings-panel.js.map +1 -1
  102. package/dist/hooks/use-vpn-status.d.ts +6 -1
  103. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  104. package/dist/hooks/use-vpn-status.js +67 -30
  105. package/dist/hooks/use-vpn-status.js.map +1 -1
  106. package/dist/hooks/use-websocket.d.ts +3 -2
  107. package/dist/hooks/use-websocket.d.ts.map +1 -1
  108. package/dist/hooks/use-websocket.js +65 -254
  109. package/dist/hooks/use-websocket.js.map +1 -1
  110. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  111. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  112. package/dist/hooks/websocket-handlers/activity.js +10 -0
  113. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  114. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  115. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  116. package/dist/hooks/websocket-handlers/chat.js +175 -0
  117. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  118. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  119. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  120. package/dist/hooks/websocket-handlers/connection.js +27 -0
  121. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  122. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  123. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  124. package/dist/hooks/websocket-handlers/index.js +19 -0
  125. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  126. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  127. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  128. package/dist/hooks/websocket-handlers/mask.js +14 -0
  129. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  130. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  131. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  132. package/dist/hooks/websocket-handlers/provider.js +31 -0
  133. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  134. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  135. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  136. package/dist/hooks/websocket-handlers/types.js +2 -0
  137. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  138. package/dist/lib/api-client.d.ts +32 -0
  139. package/dist/lib/api-client.d.ts.map +1 -1
  140. package/dist/lib/api-client.js +11 -0
  141. package/dist/lib/api-client.js.map +1 -1
  142. package/dist/lib/format-time.d.ts +3 -0
  143. package/dist/lib/format-time.d.ts.map +1 -0
  144. package/dist/lib/format-time.js +25 -0
  145. package/dist/lib/format-time.js.map +1 -0
  146. package/dist/lib/push-registration.d.ts +7 -0
  147. package/dist/lib/push-registration.d.ts.map +1 -0
  148. package/dist/lib/push-registration.js +26 -0
  149. package/dist/lib/push-registration.js.map +1 -0
  150. package/dist/stores/activity-store-helpers.d.ts +50 -0
  151. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  152. package/dist/stores/activity-store-helpers.js +143 -0
  153. package/dist/stores/activity-store-helpers.js.map +1 -0
  154. package/dist/stores/activity-store-types.d.ts +48 -0
  155. package/dist/stores/activity-store-types.d.ts.map +1 -0
  156. package/dist/stores/activity-store-types.js +2 -0
  157. package/dist/stores/activity-store-types.js.map +1 -0
  158. package/dist/stores/activity-store.d.ts +2 -78
  159. package/dist/stores/activity-store.d.ts.map +1 -1
  160. package/dist/stores/activity-store.js +22 -142
  161. package/dist/stores/activity-store.js.map +1 -1
  162. package/dist/stores/connection-store.d.ts +14 -2
  163. package/dist/stores/connection-store.d.ts.map +1 -1
  164. package/dist/stores/connection-store.js +17 -0
  165. package/dist/stores/connection-store.js.map +1 -1
  166. package/dist/stores/file-store.d.ts.map +1 -1
  167. package/dist/stores/file-store.js +54 -18
  168. package/dist/stores/file-store.js.map +1 -1
  169. package/dist/stores/principal-store.d.ts +16 -0
  170. package/dist/stores/principal-store.d.ts.map +1 -0
  171. package/dist/stores/principal-store.js +29 -0
  172. package/dist/stores/principal-store.js.map +1 -0
  173. package/dist/stores/ui-store.d.ts +1 -1
  174. package/dist/stores/ui-store.d.ts.map +1 -1
  175. package/dist/styles.css +1 -1
  176. package/package.json +2 -2
  177. package/src/components/activity/activity-page.tsx +6 -477
  178. package/src/components/activity/activity-rollups.tsx +94 -0
  179. package/src/components/activity/activity-run-detail.tsx +301 -0
  180. package/src/components/activity/activity-run-list.tsx +97 -0
  181. package/src/components/chat/brain-markdown-code.tsx +60 -0
  182. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  183. package/src/components/chat/brain-markdown-links.tsx +247 -0
  184. package/src/components/chat/brain-markdown-share.ts +47 -0
  185. package/src/components/chat/brain-markdown.tsx +27 -535
  186. package/src/components/chat/composer.tsx +14 -1
  187. package/src/components/chat/session-drawer.tsx +29 -10
  188. package/src/components/chat/tool-views.tsx +2 -13
  189. package/src/components/chat/use-markdown-highlight.ts +63 -0
  190. package/src/components/connectivity/connection-gate.tsx +128 -14
  191. package/src/components/connectivity/connection-state.ts +37 -0
  192. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  193. package/src/components/graph/graph-controls.tsx +4 -1
  194. package/src/components/graph/graph-maintenance.tsx +304 -0
  195. package/src/components/graph/graph-page.tsx +11 -840
  196. package/src/components/graph/graph-scene.tsx +548 -0
  197. package/src/components/graph/graph-spinner.tsx +9 -0
  198. package/src/components/layout/app-shell.tsx +2 -0
  199. package/src/components/quick-actions/add-modal.tsx +49 -4
  200. package/src/components/quick-actions/search-modal.tsx +46 -18
  201. package/src/components/quick-actions/streaming-modal.tsx +3 -1
  202. package/src/components/settings/devices-agents-tab.tsx +231 -0
  203. package/src/components/settings/one-time-agent-credential.tsx +131 -0
  204. package/src/components/settings/settings-panel.tsx +20 -5
  205. package/src/hooks/use-vpn-status.ts +71 -31
  206. package/src/hooks/use-websocket.ts +74 -275
  207. package/src/hooks/websocket-handlers/activity.ts +13 -0
  208. package/src/hooks/websocket-handlers/chat.ts +210 -0
  209. package/src/hooks/websocket-handlers/connection.ts +30 -0
  210. package/src/hooks/websocket-handlers/index.ts +59 -0
  211. package/src/hooks/websocket-handlers/mask.ts +17 -0
  212. package/src/hooks/websocket-handlers/provider.ts +35 -0
  213. package/src/hooks/websocket-handlers/types.ts +27 -0
  214. package/src/lib/api-client.ts +40 -1
  215. package/src/lib/format-time.ts +20 -0
  216. package/src/lib/push-registration.ts +29 -0
  217. package/src/stores/activity-store-helpers.ts +164 -0
  218. package/src/stores/activity-store-types.ts +55 -0
  219. package/src/stores/activity-store.ts +21 -217
  220. package/src/stores/connection-store.ts +33 -2
  221. package/src/stores/file-store.ts +42 -12
  222. package/src/stores/principal-store.ts +38 -0
  223. package/src/stores/ui-store.ts +1 -1
@@ -1,55 +1,91 @@
1
- import { useEffect } from "react";
2
- import { useConnectionStore } from "../stores/connection-store.js";
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ useConnectionStore,
4
+ type VpnStatus,
5
+ } from "../stores/connection-store.js";
3
6
  import { getBackendUrl } from "../lib/backend.js";
4
7
 
5
8
  const POLL_INTERVAL = 15_000;
6
9
  // Tighter cadence while offline so recovery is near-immediate
7
10
  const OFFLINE_POLL_INTERVAL = 3_000;
8
11
  const TIMEOUT_MS = 5_000;
12
+ let activeRecheck: (() => void) | null = null;
13
+
14
+ async function fetchVpnStatus(): Promise<VpnStatus> {
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
17
+ try {
18
+ const res = await fetch(getBackendUrl("/api/vpn-check"), {
19
+ signal: controller.signal,
20
+ });
21
+
22
+ if (res.ok) return "connected";
23
+ if (res.status === 401) return "unauthorized";
24
+ if (res.status === 403) return "forbidden";
25
+ return "unreachable";
26
+ } catch {
27
+ return "unreachable";
28
+ } finally {
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Ask the mounted connectivity poller for a fresh reading. The poller remains
35
+ * the only writer of vpnStatus and serializes this with its scheduled checks.
36
+ */
37
+ export function recheckVpnStatus(): void {
38
+ activeRecheck?.();
39
+ }
9
40
 
10
41
  export function useVpnStatus() {
11
- const setVpnStatus = useConnectionStore((s) => s.setVpnStatus);
42
+ const [successfulProbeCount, setSuccessfulProbeCount] = useState(0);
12
43
 
13
44
  useEffect(() => {
14
45
  let timer: ReturnType<typeof setTimeout> | undefined;
15
46
  let inFlight = false;
47
+ let recheckRequested = false;
16
48
  let disposed = false;
17
49
 
18
50
  async function check() {
19
- if (inFlight || disposed) return;
51
+ if (disposed) return;
52
+ if (inFlight) {
53
+ recheckRequested = true;
54
+ return;
55
+ }
20
56
  inFlight = true;
21
- let connected = false;
57
+ const opensAtStart = useConnectionStore.getState().socketOpens;
22
58
  try {
23
- const controller = new AbortController();
24
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
25
-
26
- const res = await fetch(getBackendUrl("/api/vpn-check"), {
27
- signal: controller.signal,
28
- });
29
- clearTimeout(timeout);
30
-
31
- if (res.ok) {
32
- setVpnStatus("connected");
33
- connected = true;
34
- } else if (res.status === 401) {
35
- // Auth required (password / proxy mode) but no valid session yet.
36
- setVpnStatus("unauthorized");
37
- connected = true; // reachable — poll at the relaxed cadence
38
- } else if (res.status === 403) {
39
- setVpnStatus("forbidden");
40
- } else {
41
- setVpnStatus("unreachable");
59
+ const status = await fetchVpnStatus();
60
+ // A socket that reached `open` while this probe was in flight is newer
61
+ // reachability evidence than a failure the probe was started for.
62
+ // Counting opens rather than sampling wsStatus twice also catches a
63
+ // drop and a replacement open inside one probe, where both samples
64
+ // read "connected".
65
+ const socketOpenedDuringCheck =
66
+ useConnectionStore.getState().socketOpens !== opensAtStart;
67
+ if (!disposed && !(socketOpenedDuringCheck && status !== "connected")) {
68
+ useConnectionStore.getState().setVpnStatus(status);
69
+ if (status === "connected") {
70
+ setSuccessfulProbeCount((count) => count + 1);
71
+ }
42
72
  }
43
- } catch {
44
- setVpnStatus("unreachable");
45
73
  } finally {
46
74
  inFlight = false;
47
75
  if (!disposed) {
48
76
  clearTimeout(timer);
49
- timer = setTimeout(
50
- check,
51
- connected ? POLL_INTERVAL : OFFLINE_POLL_INTERVAL
52
- );
77
+ if (recheckRequested) {
78
+ recheckRequested = false;
79
+ void check();
80
+ } else {
81
+ const status = useConnectionStore.getState().vpnStatus;
82
+ // Auth required but no valid session is still a reachable server.
83
+ const reachable = status === "connected" || status === "unauthorized";
84
+ timer = setTimeout(
85
+ check,
86
+ reachable ? POLL_INTERVAL : OFFLINE_POLL_INTERVAL
87
+ );
88
+ }
53
89
  }
54
90
  }
55
91
  }
@@ -57,6 +93,7 @@ export function useVpnStatus() {
57
93
  // Re-check immediately when the network returns or the PWA is foregrounded
58
94
  // instead of waiting out the poll interval.
59
95
  const recheckNow = () => void check();
96
+ activeRecheck = recheckNow;
60
97
  const onVisible = () => {
61
98
  if (document.visibilityState === "visible") void check();
62
99
  };
@@ -68,8 +105,11 @@ export function useVpnStatus() {
68
105
  return () => {
69
106
  disposed = true;
70
107
  clearTimeout(timer);
108
+ if (activeRecheck === recheckNow) activeRecheck = null;
71
109
  window.removeEventListener("online", recheckNow);
72
110
  document.removeEventListener("visibilitychange", onVisible);
73
111
  };
74
- }, [setVpnStatus]);
112
+ }, []);
113
+
114
+ return successfulProbeCount;
75
115
  }
@@ -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
@@ -130,9 +37,11 @@ function isOurDraftAnnouncement(
130
37
  state: ReturnType<typeof useChatStore.getState>,
131
38
  msg: ServerMessage
132
39
  ): boolean {
133
- if (msg.type !== "session_info" && msg.type !== "result") return false;
40
+ // A terminal result has no draft correlation. Another client's turn may
41
+ // finish before our session_info arrives, so it can never claim our draft.
42
+ if (msg.type !== "session_info") return false;
134
43
  const pending = state.pendingDraftId;
135
- if (msg.type === "session_info" && msg.draftId) return msg.draftId === pending;
44
+ if (msg.draftId) return msg.draftId === pending;
136
45
  // No echo to compare against.
137
46
  return true;
138
47
  }
@@ -226,21 +135,24 @@ export function handleServerMessage(msg: ServerMessage) {
226
135
  const state = useChatStore.getState();
227
136
 
228
137
  // 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);
138
+ if (
139
+ msg.type === "server_hello" ||
140
+ msg.type === "activity_snapshot" ||
141
+ msg.type === "activity_delta"
142
+ ) {
143
+ const key = state.activeSessionId;
144
+ dispatchServerMessage(msg, {
145
+ state,
146
+ frameSessionId: undefined,
147
+ key,
148
+ buffer: () => (key === null ? state.draft : state.buffers[key]),
149
+ enqueueDelta,
150
+ ensureActivitySubscription,
151
+ requestBrowserLocation,
152
+ resyncIfNeeded,
153
+ coldResumeIfNeeded,
154
+ markHistoryReplaced,
155
+ });
244
156
  return;
245
157
  }
246
158
 
@@ -290,162 +202,18 @@ export function handleServerMessage(msg: ServerMessage) {
290
202
  return key === null ? s.draft : s.buffers[key];
291
203
  };
292
204
 
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
- }
205
+ dispatchServerMessage(msg, {
206
+ state,
207
+ frameSessionId,
208
+ key,
209
+ buffer,
210
+ enqueueDelta,
211
+ ensureActivitySubscription,
212
+ requestBrowserLocation,
213
+ resyncIfNeeded,
214
+ coldResumeIfNeeded,
215
+ markHistoryReplaced,
216
+ });
449
217
  }
450
218
 
451
219
  /**
@@ -509,6 +277,17 @@ function requestBrowserLocation(msg: ServerLocationRequest) {
509
277
  let wasDisconnected = false;
510
278
  let resyncSessionId: string | null = null;
511
279
 
280
+ function handleSocketClose(close: WebSocketClose): void {
281
+ const connection = useConnectionStore.getState();
282
+ connection.recordWsClose(close.opened, close.code);
283
+ if (
284
+ !close.opened &&
285
+ useConnectionStore.getState().handshakeFailures >= REFUSAL_ATTEMPTS
286
+ ) {
287
+ recheckVpnStatus();
288
+ }
289
+ }
290
+
512
291
  function resyncIfNeeded(sessionId: string | null) {
513
292
  if (!resyncSessionId || !sessionId || sessionId !== resyncSessionId) return;
514
293
  resyncSessionId = null;
@@ -524,6 +303,11 @@ function resyncIfNeeded(sessionId: string | null) {
524
303
  // status and loop.
525
304
  let coldResumedSessionId: string | null = null;
526
305
 
306
+ function markHistoryReplaced(key: ChatKey): void {
307
+ if (key !== null && key === resyncSessionId) resyncSessionId = null;
308
+ coldResumedSessionId = key;
309
+ }
310
+
527
311
  function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
528
312
  if (!sessionId || messageCount > 0) return;
529
313
  if (coldResumedSessionId === sessionId) return;
@@ -534,6 +318,10 @@ function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
534
318
  function handleStatusChange(status: "connecting" | "connected" | "disconnected") {
535
319
  useConnectionStore.getState().setWsStatus(status);
536
320
 
321
+ if (status === "connected") {
322
+ useConnectionStore.getState().noteSocketOpen();
323
+ }
324
+
537
325
  // A status change can be followed by a history replay that rewrites the
538
326
  // buffer, so land whatever is still buffered before anything reads it. In a
539
327
  // hidden tab requestAnimationFrame does not run at all, which is exactly the
@@ -581,12 +369,19 @@ export function sendClientMessage(msg: ClientMessage): boolean {
581
369
  return true;
582
370
  }
583
371
 
372
+ /** Retry the singleton socket immediately, skipping any remaining backoff. */
373
+ export function reconnectWebSocketNow(): void {
374
+ wsClient?.reconnectNow();
375
+ }
376
+
584
377
  export function useWebSocket() {
585
378
  const initialized = useRef(false);
586
379
 
587
380
  useEffect(() => {
588
381
  if (initialized.current) return;
589
382
  initialized.current = true;
383
+ let disposed = false;
384
+ useConnectionStore.getState().noteSocketOpen();
590
385
 
591
386
  wsClient = new BrainUiClient({
592
387
  url: getWsUrl(),
@@ -594,6 +389,9 @@ export function useWebSocket() {
594
389
  // session-buffer demux — see handleServerMessage.
595
390
  handlers: { onAny: handleServerMessage },
596
391
  onStatusChange: handleStatusChange,
392
+ onClose: (close) => {
393
+ if (!disposed) handleSocketClose(close);
394
+ },
597
395
  // A frame the SDK refused. Surfaced rather than logged into a console
598
396
  // nobody is attached to; the server-side counterpart is the
599
397
  // ws.frames.dropped counter.
@@ -619,6 +417,7 @@ export function useWebSocket() {
619
417
  document.addEventListener("visibilitychange", onVisible);
620
418
 
621
419
  return () => {
420
+ disposed = true;
622
421
  window.removeEventListener("online", reconnectNow);
623
422
  document.removeEventListener("visibilitychange", onVisible);
624
423
  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>;