@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.
- package/README.md +5 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +8 -149
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/activity-rollups.d.ts +5 -0
- package/dist/components/activity/activity-rollups.d.ts.map +1 -0
- package/dist/components/activity/activity-rollups.js +39 -0
- package/dist/components/activity/activity-rollups.js.map +1 -0
- package/dist/components/activity/activity-run-detail.d.ts +11 -0
- package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
- package/dist/components/activity/activity-run-detail.js +111 -0
- package/dist/components/activity/activity-run-detail.js.map +1 -0
- package/dist/components/activity/activity-run-list.d.ts +15 -0
- package/dist/components/activity/activity-run-list.d.ts.map +1 -0
- package/dist/components/activity/activity-run-list.js +25 -0
- package/dist/components/activity/activity-run-list.js.map +1 -0
- package/dist/components/chat/brain-markdown-code.d.ts +4 -0
- package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-code.js +47 -0
- package/dist/components/chat/brain-markdown-code.js.map +1 -0
- package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
- package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-entities.js +93 -0
- package/dist/components/chat/brain-markdown-entities.js.map +1 -0
- package/dist/components/chat/brain-markdown-links.d.ts +36 -0
- package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-links.js +176 -0
- package/dist/components/chat/brain-markdown-links.js.map +1 -0
- package/dist/components/chat/brain-markdown-share.d.ts +15 -0
- package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-share.js +40 -0
- package/dist/components/chat/brain-markdown-share.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +1 -34
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +9 -402
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/composer.d.ts.map +1 -1
- package/dist/components/chat/composer.js +14 -1
- package/dist/components/chat/composer.js.map +1 -1
- package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
- package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
- package/dist/components/chat/use-markdown-highlight.js +56 -0
- package/dist/components/chat/use-markdown-highlight.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
- package/dist/components/connectivity/connection-gate.js +41 -7
- package/dist/components/connectivity/connection-gate.js.map +1 -1
- package/dist/components/connectivity/connection-state.d.ts +12 -0
- package/dist/components/connectivity/connection-state.d.ts.map +1 -0
- package/dist/components/connectivity/connection-state.js +23 -0
- package/dist/components/connectivity/connection-state.js.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.js +4 -0
- package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
- package/dist/components/graph/graph-controls.js +4 -1
- package/dist/components/graph/graph-controls.js.map +1 -1
- package/dist/components/graph/graph-maintenance.d.ts +8 -0
- package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
- package/dist/components/graph/graph-maintenance.js +115 -0
- package/dist/components/graph/graph-maintenance.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -1
- package/dist/components/graph/graph-page.js +11 -306
- package/dist/components/graph/graph-page.js.map +1 -1
- package/dist/components/graph/graph-scene.d.ts +8 -0
- package/dist/components/graph/graph-scene.d.ts.map +1 -0
- package/dist/components/graph/graph-scene.js +208 -0
- package/dist/components/graph/graph-scene.js.map +1 -0
- package/dist/components/graph/graph-spinner.d.ts +2 -0
- package/dist/components/graph/graph-spinner.d.ts.map +1 -0
- package/dist/components/graph/graph-spinner.js +6 -0
- package/dist/components/graph/graph-spinner.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +5 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -1
- package/dist/hooks/use-vpn-status.js +61 -29
- package/dist/hooks/use-vpn-status.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +3 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +61 -252
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
- package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/activity.js +10 -0
- package/dist/hooks/websocket-handlers/activity.js.map +1 -0
- package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
- package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/chat.js +175 -0
- package/dist/hooks/websocket-handlers/chat.js.map +1 -0
- package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
- package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/connection.js +27 -0
- package/dist/hooks/websocket-handlers/connection.js.map +1 -0
- package/dist/hooks/websocket-handlers/index.d.ts +4 -0
- package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/index.js +19 -0
- package/dist/hooks/websocket-handlers/index.js.map +1 -0
- package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
- package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/mask.js +14 -0
- package/dist/hooks/websocket-handlers/mask.js.map +1 -0
- package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
- package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/provider.js +31 -0
- package/dist/hooks/websocket-handlers/provider.js.map +1 -0
- package/dist/hooks/websocket-handlers/types.d.ts +24 -0
- package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/types.js +2 -0
- package/dist/hooks/websocket-handlers/types.js.map +1 -0
- package/dist/stores/activity-store-helpers.d.ts +50 -0
- package/dist/stores/activity-store-helpers.d.ts.map +1 -0
- package/dist/stores/activity-store-helpers.js +143 -0
- package/dist/stores/activity-store-helpers.js.map +1 -0
- package/dist/stores/activity-store-types.d.ts +48 -0
- package/dist/stores/activity-store-types.d.ts.map +1 -0
- package/dist/stores/activity-store-types.js +2 -0
- package/dist/stores/activity-store-types.js.map +1 -0
- package/dist/stores/activity-store.d.ts +2 -78
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +22 -142
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +14 -2
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +17 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +6 -477
- package/src/components/activity/activity-rollups.tsx +94 -0
- package/src/components/activity/activity-run-detail.tsx +301 -0
- package/src/components/activity/activity-run-list.tsx +97 -0
- package/src/components/chat/brain-markdown-code.tsx +60 -0
- package/src/components/chat/brain-markdown-entities.tsx +116 -0
- package/src/components/chat/brain-markdown-links.tsx +247 -0
- package/src/components/chat/brain-markdown-share.ts +47 -0
- package/src/components/chat/brain-markdown.tsx +27 -535
- package/src/components/chat/composer.tsx +14 -1
- package/src/components/chat/use-markdown-highlight.ts +63 -0
- package/src/components/connectivity/connection-gate.tsx +64 -13
- package/src/components/connectivity/connection-state.ts +37 -0
- package/src/components/graph/graph-canvas-lazy.tsx +4 -0
- package/src/components/graph/graph-controls.tsx +4 -1
- package/src/components/graph/graph-maintenance.tsx +304 -0
- package/src/components/graph/graph-page.tsx +11 -840
- package/src/components/graph/graph-scene.tsx +548 -0
- package/src/components/graph/graph-spinner.tsx +9 -0
- package/src/hooks/use-vpn-status.ts +64 -31
- package/src/hooks/use-websocket.ts +70 -273
- package/src/hooks/websocket-handlers/activity.ts +13 -0
- package/src/hooks/websocket-handlers/chat.ts +210 -0
- package/src/hooks/websocket-handlers/connection.ts +30 -0
- package/src/hooks/websocket-handlers/index.ts +59 -0
- package/src/hooks/websocket-handlers/mask.ts +17 -0
- package/src/hooks/websocket-handlers/provider.ts +35 -0
- package/src/hooks/websocket-handlers/types.ts +27 -0
- package/src/stores/activity-store-helpers.ts +164 -0
- package/src/stores/activity-store-types.ts +55 -0
- package/src/stores/activity-store.ts +21 -217
- package/src/stores/connection-store.ts +33 -2
|
@@ -1,116 +1,23 @@
|
|
|
1
1
|
import { useEffect, useRef, useCallback } from "react";
|
|
2
|
-
import {
|
|
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
|
|
17
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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>;
|