@schlessera/brain-ui-react 0.15.0 → 0.17.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 (47) hide show
  1. package/dist/components/chat/chat-page.d.ts.map +1 -1
  2. package/dist/components/chat/chat-page.js +4 -0
  3. package/dist/components/chat/chat-page.js.map +1 -1
  4. package/dist/components/chat/risk-hints.js +1 -1
  5. package/dist/components/chat/risk-hints.js.map +1 -1
  6. package/dist/components/chat/tool-views.d.ts +2 -19
  7. package/dist/components/chat/tool-views.d.ts.map +1 -1
  8. package/dist/components/chat/tool-views.js +3 -125
  9. package/dist/components/chat/tool-views.js.map +1 -1
  10. package/dist/hooks/use-websocket.d.ts +2 -2
  11. package/dist/hooks/use-websocket.d.ts.map +1 -1
  12. package/dist/hooks/use-websocket.js +49 -5
  13. package/dist/hooks/use-websocket.js.map +1 -1
  14. package/dist/lib/diff.d.ts +19 -0
  15. package/dist/lib/diff.d.ts.map +1 -0
  16. package/dist/lib/diff.js +129 -0
  17. package/dist/lib/diff.js.map +1 -0
  18. package/dist/stores/chat-store.d.ts +13 -0
  19. package/dist/stores/chat-store.d.ts.map +1 -1
  20. package/dist/stores/chat-store.js +28 -4
  21. package/dist/stores/chat-store.js.map +1 -1
  22. package/dist/stores/connection-store.d.ts +18 -0
  23. package/dist/stores/connection-store.d.ts.map +1 -1
  24. package/dist/stores/connection-store.js +3 -0
  25. package/dist/stores/connection-store.js.map +1 -1
  26. package/dist/stores/file-store.d.ts.map +1 -1
  27. package/dist/stores/file-store.js +10 -0
  28. package/dist/stores/file-store.js.map +1 -1
  29. package/dist/stores/graph-store.d.ts.map +1 -1
  30. package/dist/stores/graph-store.js +14 -1
  31. package/dist/stores/graph-store.js.map +1 -1
  32. package/dist/theme.css +435 -0
  33. package/package.json +14 -8
  34. package/src/components/chat/chat-page.tsx +4 -0
  35. package/src/components/chat/risk-hints.ts +1 -1
  36. package/src/components/chat/tool-views.tsx +4 -137
  37. package/src/hooks/use-websocket.ts +55 -6
  38. package/src/lib/diff.ts +140 -0
  39. package/src/stores/chat-store.ts +43 -4
  40. package/src/stores/connection-store.ts +22 -0
  41. package/src/stores/file-store.ts +8 -0
  42. package/src/stores/graph-store.ts +14 -1
  43. package/dist/lib/ws-client.d.ts +0 -26
  44. package/dist/lib/ws-client.d.ts.map +0 -1
  45. package/dist/lib/ws-client.js +0 -93
  46. package/dist/lib/ws-client.js.map +0 -1
  47. package/src/lib/ws-client.ts +0 -109
@@ -21,6 +21,10 @@ import { linkifyPaths, FileLink } from "./brain-markdown.js";
21
21
  import { MarkdownContent } from "./markdown-content.js";
22
22
  import { isInternalRepoPath } from "../../stores/file-store.js";
23
23
  import { GET_LOCATION_TOOL_NAME, normalizeToolName } from "../../lib/tool-names.js";
24
+ import { computeDiffRows, type WordToken } from "../../lib/diff.js";
25
+
26
+ // Re-exported so existing imports of the diff engine from this module keep working.
27
+ export { computeDiffRows };
24
28
 
25
29
  // ============================================================
26
30
  // Shared helpers
@@ -265,143 +269,6 @@ function PathHeader({ path, badge }: { path: string | null; badge?: string | nul
265
269
  );
266
270
  }
267
271
 
268
- // ------------------------------------------------------------
269
- // Diff helpers — line-level LCS with word-level refinement
270
- // ------------------------------------------------------------
271
-
272
- type DiffOp<T> = { kind: "same" | "del" | "ins"; value: T };
273
-
274
- /**
275
- * Hand-rolled LCS diff (standard DP + forward walk) so we avoid pulling in the
276
- * `diff` package. Returns ops in source order: `same` where the sequences
277
- * agree, `del` for items only in `a`, `ins` for items only in `b`.
278
- */
279
- function diffSeq<T>(
280
- a: T[],
281
- b: T[],
282
- eq: (x: T, y: T) => boolean = (x, y) => x === y
283
- ): DiffOp<T>[] {
284
- const m = a.length;
285
- const n = b.length;
286
- // dp[i][j] = length of the LCS of a[i:] and b[j:]
287
- const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
288
- for (let i = m - 1; i >= 0; i--) {
289
- for (let j = n - 1; j >= 0; j--) {
290
- dp[i][j] = eq(a[i], b[j])
291
- ? dp[i + 1][j + 1] + 1
292
- : Math.max(dp[i + 1][j], dp[i][j + 1]);
293
- }
294
- }
295
- const ops: DiffOp<T>[] = [];
296
- let i = 0;
297
- let j = 0;
298
- while (i < m && j < n) {
299
- if (eq(a[i], b[j])) {
300
- ops.push({ kind: "same", value: a[i] });
301
- i++;
302
- j++;
303
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
304
- ops.push({ kind: "del", value: a[i] });
305
- i++;
306
- } else {
307
- ops.push({ kind: "ins", value: b[j] });
308
- j++;
309
- }
310
- }
311
- while (i < m) ops.push({ kind: "del", value: a[i++] });
312
- while (j < n) ops.push({ kind: "ins", value: b[j++] });
313
- return ops;
314
- }
315
-
316
- /** A word token, flagged when it differs from its counterpart line. */
317
- type WordToken = { text: string; changed: boolean };
318
-
319
- /**
320
- * Token-level LCS for a single deleted/inserted line pair. Splitting on
321
- * `/(\s+)/` keeps the whitespace as its own tokens so we can reassemble the
322
- * line exactly. Unchanged tokens are shared between both sides.
323
- */
324
- function wordDiff(oldLine: string, newLine: string): { del: WordToken[]; ins: WordToken[] } {
325
- const a = oldLine.split(/(\s+)/);
326
- const b = newLine.split(/(\s+)/);
327
- // Token-level LCS is O(words²); a pathological minified line would be slow.
328
- // Above the cap, skip word refinement and flag the whole line as changed.
329
- if (a.length * b.length > 10_000) {
330
- return {
331
- del: [{ text: oldLine, changed: true }],
332
- ins: [{ text: newLine, changed: true }],
333
- };
334
- }
335
- const ops = diffSeq(a, b);
336
- const del: WordToken[] = [];
337
- const ins: WordToken[] = [];
338
- for (const op of ops) {
339
- if (op.kind === "same") {
340
- del.push({ text: op.value, changed: false });
341
- ins.push({ text: op.value, changed: false });
342
- } else if (op.kind === "del") {
343
- del.push({ text: op.value, changed: true });
344
- } else {
345
- ins.push({ text: op.value, changed: true });
346
- }
347
- }
348
- return { del, ins };
349
- }
350
-
351
- /** A rendered diff row. `tokens` is set only for word-refined single-line edits. */
352
- type DiffRow = {
353
- kind: "same" | "del" | "ins";
354
- line: string;
355
- tokens: WordToken[] | null;
356
- };
357
-
358
- /**
359
- * Merge `old_string`/`new_string` into a single diff: unchanged lines appear
360
- * once as neutral, deletions/insertions keep the red/green treatment. A change
361
- * block that is exactly one deleted line against one inserted line gets
362
- * word-level highlighting.
363
- */
364
- export function computeDiffRows(oldStr: string, newStr: string): DiffRow[] {
365
- const oldLines = oldStr.split("\n");
366
- const newLines = newStr.split("\n");
367
- // Line-level LCS is O(m×n) in time and memory; a huge Edit (thousands of
368
- // lines) would build a massive DP matrix and hang the UI. Above the cap, fall
369
- // back to a naive "all old removed, all new added" diff.
370
- if (oldLines.length * newLines.length > 250_000) {
371
- return [
372
- ...oldLines.map((line): DiffRow => ({ kind: "del", line, tokens: null })),
373
- ...newLines.map((line): DiffRow => ({ kind: "ins", line, tokens: null })),
374
- ];
375
- }
376
- const ops = diffSeq(oldLines, newLines);
377
- const rows: DiffRow[] = [];
378
- let idx = 0;
379
- while (idx < ops.length) {
380
- if (ops[idx].kind === "same") {
381
- rows.push({ kind: "same", line: ops[idx].value, tokens: null });
382
- idx++;
383
- continue;
384
- }
385
- // Collect a maximal run of changes, grouping deletes before inserts.
386
- const dels: string[] = [];
387
- const inss: string[] = [];
388
- while (idx < ops.length && ops[idx].kind !== "same") {
389
- if (ops[idx].kind === "del") dels.push(ops[idx].value);
390
- else inss.push(ops[idx].value);
391
- idx++;
392
- }
393
- if (dels.length === 1 && inss.length === 1) {
394
- const { del, ins } = wordDiff(dels[0], inss[0]);
395
- rows.push({ kind: "del", line: dels[0], tokens: del });
396
- rows.push({ kind: "ins", line: inss[0], tokens: ins });
397
- } else {
398
- for (const d of dels) rows.push({ kind: "del", line: d, tokens: null });
399
- for (const s of inss) rows.push({ kind: "ins", line: s, tokens: null });
400
- }
401
- }
402
- return rows;
403
- }
404
-
405
272
  /** Render a diff line, emphasizing changed word tokens when refined. */
406
273
  function DiffLine({ tokens, line, changedClass }: {
407
274
  tokens: WordToken[] | null;
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
- import { WSClient } from "../lib/ws-client.js";
2
+ import { BrainUiClient } from "@schlessera/brain-ui-sdk/client";
3
3
  import { useConnectionStore } from "../stores/connection-store.js";
4
4
  import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
5
  import { useProviderStore } from "../stores/provider-store.js";
@@ -96,6 +96,31 @@ export function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "
96
96
  return "streaming";
97
97
  }
98
98
 
99
+ /**
100
+ * Does this frame announce the identity of the conversation THIS client just
101
+ * started?
102
+ *
103
+ * The old test was `session_info || result` for any unknown session, which
104
+ * adopted whichever arrived first — an older background turn finishing, or
105
+ * another client's new session, would capture the user's draft and bind it to
106
+ * a transcript they never wrote. `draftId` is minted per draft turn and echoed
107
+ * on `session_info`, so a match is proof.
108
+ *
109
+ * A server too old to echo it sends no `draftId`, and the pre-existing
110
+ * behaviour applies rather than the draft never binding at all: correctness
111
+ * where the information exists, compatibility where it does not.
112
+ */
113
+ function isOurDraftAnnouncement(
114
+ state: ReturnType<typeof useChatStore.getState>,
115
+ msg: ServerMessage
116
+ ): boolean {
117
+ if (msg.type !== "session_info" && msg.type !== "result") return false;
118
+ const pending = state.pendingDraftId;
119
+ if (msg.type === "session_info" && msg.draftId) return msg.draftId === pending;
120
+ // No echo to compare against.
121
+ return true;
122
+ }
123
+
99
124
  export function handleServerMessage(msg: ServerMessage) {
100
125
  const state = useChatStore.getState();
101
126
 
@@ -119,7 +144,7 @@ export function handleServerMessage(msg: ServerMessage) {
119
144
  key = state.activeSessionId; // null = the draft view
120
145
  } else if (state.buffers[frameSessionId]) {
121
146
  key = frameSessionId;
122
- } else if (state.draft && (msg.type === "session_info" || msg.type === "result")) {
147
+ } else if (state.draft && isOurDraftAnnouncement(state, msg)) {
123
148
  // A draft run just got its server identity: adopt the draft buffer.
124
149
  state.bindDraftSession(frameSessionId);
125
150
  key = frameSessionId;
@@ -268,6 +293,11 @@ export function handleServerMessage(msg: ServerMessage) {
268
293
  }
269
294
 
270
295
  case "error":
296
+ // ALWAYS recorded. Appending to the transcript only works while a
297
+ // message is streaming, and this used to be the whole handler — so an
298
+ // error arriving between turns (a rejected frame, a failed resume) was
299
+ // dropped as silently on the client as it was on the server.
300
+ useConnectionStore.getState().reportError(msg.code, msg.message);
271
301
  if (buffer()?.isStreaming) {
272
302
  state.appendText(key, `\n\n**Error:** ${msg.message}`);
273
303
  state.finishAssistantMessage(key);
@@ -382,7 +412,7 @@ function handleStatusChange(status: "connecting" | "connected" | "disconnected")
382
412
  }
383
413
 
384
414
  // Singleton client - survives React re-renders
385
- let wsClient: WSClient | null = null;
415
+ let wsClient: BrainUiClient | null = null;
386
416
 
387
417
  /**
388
418
  * Send on the live socket from outside a component.
@@ -393,8 +423,8 @@ let wsClient: WSClient | null = null;
393
423
  * socket for both. Anything that needs to send but not to own (the share
394
424
  * intake) goes through here instead of calling the hook again.
395
425
  *
396
- * Returns false when there is no open socket, since `WSClient.send` drops
397
- * silently in that case and a caller that just staged an upload needs to know.
426
+ * Returns false when there is no open socket: `send` drops silently in that
427
+ * case and a caller that just staged an upload needs to know.
398
428
  */
399
429
  export function sendClientMessage(msg: ClientMessage): boolean {
400
430
  if (!wsClient) return false;
@@ -410,7 +440,26 @@ export function useWebSocket() {
410
440
  if (initialized.current) return;
411
441
  initialized.current = true;
412
442
 
413
- wsClient = new WSClient(getWsUrl(), handleServerMessage, handleStatusChange);
443
+ wsClient = new BrainUiClient({
444
+ url: getWsUrl(),
445
+ // One handler with a shared preamble, rather than sixteen copies of the
446
+ // session-buffer demux — see handleServerMessage.
447
+ handlers: { onAny: handleServerMessage },
448
+ onStatusChange: handleStatusChange,
449
+ // A frame the SDK refused. Surfaced rather than logged into a console
450
+ // nobody is attached to; the server-side counterpart is the
451
+ // ws.frames.dropped counter.
452
+ onProtocolError: (err) => {
453
+ useConnectionStore
454
+ .getState()
455
+ .reportError(
456
+ "PROTOCOL_ERROR",
457
+ err.frameType
458
+ ? `Dropped a ${err.frameType} frame: ${err.detail}`
459
+ : `Dropped an unreadable frame: ${err.detail}`
460
+ );
461
+ },
462
+ });
414
463
  wsClient.connect();
415
464
 
416
465
  // Skip the exponential backoff when the network demonstrably returns.
@@ -0,0 +1,140 @@
1
+ // Standalone text-diff engine behind the Edit tool's diff view: line-level
2
+ // LCS with word-level refinement for single-line change pairs. Pure data —
3
+ // no React; rendering stays in components/chat/tool-views.tsx.
4
+
5
+ // ------------------------------------------------------------
6
+ // Diff helpers — line-level LCS with word-level refinement
7
+ // ------------------------------------------------------------
8
+
9
+ type DiffOp<T> = { kind: "same" | "del" | "ins"; value: T };
10
+
11
+ /**
12
+ * Hand-rolled LCS diff (standard DP + forward walk) so we avoid pulling in the
13
+ * `diff` package. Returns ops in source order: `same` where the sequences
14
+ * agree, `del` for items only in `a`, `ins` for items only in `b`.
15
+ */
16
+ function diffSeq<T>(
17
+ a: T[],
18
+ b: T[],
19
+ eq: (x: T, y: T) => boolean = (x, y) => x === y
20
+ ): DiffOp<T>[] {
21
+ const m = a.length;
22
+ const n = b.length;
23
+ // dp[i][j] = length of the LCS of a[i:] and b[j:]
24
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
25
+ for (let i = m - 1; i >= 0; i--) {
26
+ for (let j = n - 1; j >= 0; j--) {
27
+ dp[i][j] = eq(a[i], b[j])
28
+ ? dp[i + 1][j + 1] + 1
29
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
30
+ }
31
+ }
32
+ const ops: DiffOp<T>[] = [];
33
+ let i = 0;
34
+ let j = 0;
35
+ while (i < m && j < n) {
36
+ if (eq(a[i], b[j])) {
37
+ ops.push({ kind: "same", value: a[i] });
38
+ i++;
39
+ j++;
40
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
41
+ ops.push({ kind: "del", value: a[i] });
42
+ i++;
43
+ } else {
44
+ ops.push({ kind: "ins", value: b[j] });
45
+ j++;
46
+ }
47
+ }
48
+ while (i < m) ops.push({ kind: "del", value: a[i++] });
49
+ while (j < n) ops.push({ kind: "ins", value: b[j++] });
50
+ return ops;
51
+ }
52
+
53
+ /** A word token, flagged when it differs from its counterpart line. */
54
+ export type WordToken = { text: string; changed: boolean };
55
+
56
+ /**
57
+ * Token-level LCS for a single deleted/inserted line pair. Splitting on
58
+ * `/(\s+)/` keeps the whitespace as its own tokens so we can reassemble the
59
+ * line exactly. Unchanged tokens are shared between both sides.
60
+ */
61
+ function wordDiff(oldLine: string, newLine: string): { del: WordToken[]; ins: WordToken[] } {
62
+ const a = oldLine.split(/(\s+)/);
63
+ const b = newLine.split(/(\s+)/);
64
+ // Token-level LCS is O(words²); a pathological minified line would be slow.
65
+ // Above the cap, skip word refinement and flag the whole line as changed.
66
+ if (a.length * b.length > 10_000) {
67
+ return {
68
+ del: [{ text: oldLine, changed: true }],
69
+ ins: [{ text: newLine, changed: true }],
70
+ };
71
+ }
72
+ const ops = diffSeq(a, b);
73
+ const del: WordToken[] = [];
74
+ const ins: WordToken[] = [];
75
+ for (const op of ops) {
76
+ if (op.kind === "same") {
77
+ del.push({ text: op.value, changed: false });
78
+ ins.push({ text: op.value, changed: false });
79
+ } else if (op.kind === "del") {
80
+ del.push({ text: op.value, changed: true });
81
+ } else {
82
+ ins.push({ text: op.value, changed: true });
83
+ }
84
+ }
85
+ return { del, ins };
86
+ }
87
+
88
+ /** A rendered diff row. `tokens` is set only for word-refined single-line edits. */
89
+ export type DiffRow = {
90
+ kind: "same" | "del" | "ins";
91
+ line: string;
92
+ tokens: WordToken[] | null;
93
+ };
94
+
95
+ /**
96
+ * Merge `old_string`/`new_string` into a single diff: unchanged lines appear
97
+ * once as neutral, deletions/insertions keep the red/green treatment. A change
98
+ * block that is exactly one deleted line against one inserted line gets
99
+ * word-level highlighting.
100
+ */
101
+ export function computeDiffRows(oldStr: string, newStr: string): DiffRow[] {
102
+ const oldLines = oldStr.split("\n");
103
+ const newLines = newStr.split("\n");
104
+ // Line-level LCS is O(m×n) in time and memory; a huge Edit (thousands of
105
+ // lines) would build a massive DP matrix and hang the UI. Above the cap, fall
106
+ // back to a naive "all old removed, all new added" diff.
107
+ if (oldLines.length * newLines.length > 250_000) {
108
+ return [
109
+ ...oldLines.map((line): DiffRow => ({ kind: "del", line, tokens: null })),
110
+ ...newLines.map((line): DiffRow => ({ kind: "ins", line, tokens: null })),
111
+ ];
112
+ }
113
+ const ops = diffSeq(oldLines, newLines);
114
+ const rows: DiffRow[] = [];
115
+ let idx = 0;
116
+ while (idx < ops.length) {
117
+ if (ops[idx].kind === "same") {
118
+ rows.push({ kind: "same", line: ops[idx].value, tokens: null });
119
+ idx++;
120
+ continue;
121
+ }
122
+ // Collect a maximal run of changes, grouping deletes before inserts.
123
+ const dels: string[] = [];
124
+ const inss: string[] = [];
125
+ while (idx < ops.length && ops[idx].kind !== "same") {
126
+ if (ops[idx].kind === "del") dels.push(ops[idx].value);
127
+ else inss.push(ops[idx].value);
128
+ idx++;
129
+ }
130
+ if (dels.length === 1 && inss.length === 1) {
131
+ const { del, ins } = wordDiff(dels[0], inss[0]);
132
+ rows.push({ kind: "del", line: dels[0], tokens: del });
133
+ rows.push({ kind: "ins", line: inss[0], tokens: ins });
134
+ } else {
135
+ for (const d of dels) rows.push({ kind: "del", line: d, tokens: null });
136
+ for (const s of inss) rows.push({ kind: "ins", line: s, tokens: null });
137
+ }
138
+ }
139
+ return rows;
140
+ }
@@ -103,6 +103,17 @@ interface ChatState {
103
103
  buffers: Record<string, SessionChat>;
104
104
  /** The unbound new-conversation buffer, if one is in progress. */
105
105
  draft: SessionChat | null;
106
+ /**
107
+ * Correlation id for the conversation the draft is currently starting.
108
+ *
109
+ * A client starting a conversation has no session id, so it used to adopt
110
+ * the FIRST `session_info` for an unknown session — which could be an older
111
+ * background turn's, or another client's, silently binding the user's draft
112
+ * to someone else's transcript. The id is sent on `chat_message` and echoed
113
+ * on `session_info`; adoption now requires a match. Null when no draft turn
114
+ * is in flight.
115
+ */
116
+ pendingDraftId: string | null;
106
117
  /** The session in view; null = the draft / new-chat view. */
107
118
  activeSessionId: string | null;
108
119
 
@@ -168,6 +179,8 @@ interface ChatState {
168
179
  * If the draft view is active, the view follows. No-op draft = empty buffer.
169
180
  */
170
181
  bindDraftSession: (sessionId: string) => void;
182
+ /** Mint and remember the correlation id for a draft turn about to be sent. */
183
+ startDraftTurn: () => string;
171
184
  /** Switch the view to a session (creating an empty buffer if none), or to the draft (null). */
172
185
  setActiveSession: (sessionId: string | null) => void;
173
186
  /** New chat: drop the draft, unbind the view, unpin the provider. */
@@ -302,10 +315,25 @@ export const useChatStore = create<ChatState>((set, get) => {
302
315
  ): void {
303
316
  mutateBuffer(key, (chat) => {
304
317
  const msgs = [...chat.messages];
305
- const last = msgs[msgs.length - 1];
318
+ // The last ASSISTANT message, not the last message.
319
+ //
320
+ // A follow-up sent mid-stream appends a user message to the end of the
321
+ // buffer while the assistant is still streaming into the message before
322
+ // it. Indexing the end therefore aimed every subsequent delta at the
323
+ // user's own message, where `role === "assistant"` failed and the write
324
+ // was silently discarded — the turn kept running and its output stopped
325
+ // appearing. Scanning backwards costs nothing at these lengths and
326
+ // leaves single-message behaviour identical.
327
+ let index = -1;
328
+ for (let i = msgs.length - 1; i >= 0; i--) {
329
+ if (msgs[i].role === "assistant") {
330
+ index = i;
331
+ break;
332
+ }
333
+ }
306
334
  let out: Partial<SessionChat> = {};
307
- if (last?.role === "assistant") {
308
- msgs[msgs.length - 1] = fn(last);
335
+ if (index !== -1) {
336
+ msgs[index] = fn(msgs[index]);
309
337
  out = { messages: msgs };
310
338
  }
311
339
  return extra ? { ...out, ...extra(chat) } : out;
@@ -315,6 +343,7 @@ export const useChatStore = create<ChatState>((set, get) => {
315
343
  return {
316
344
  buffers: {},
317
345
  draft: null,
346
+ pendingDraftId: null,
318
347
  // localStorage (not sessionStorage) so the active session id survives the
319
348
  // PWA process being killed on mobile — that durability is what lets a cold
320
349
  // relaunch re-request the full transcript instead of showing nothing.
@@ -427,7 +456,7 @@ export const useChatStore = create<ChatState>((set, get) => {
427
456
  ),
428
457
  })),
429
458
 
430
- requestToolApproval: (key, toolUseId, toolName, input, description) =>
459
+ requestToolApproval: (key, toolUseId, toolName, input, _description) =>
431
460
  mutateLastAssistant(key, (last) => {
432
461
  // Check if tool call already exists (from streaming)
433
462
  const existingIdx = last.toolCalls.findIndex(
@@ -574,6 +603,15 @@ export const useChatStore = create<ChatState>((set, get) => {
574
603
  };
575
604
  }),
576
605
 
606
+ startDraftTurn: () => {
607
+ const id =
608
+ typeof crypto !== "undefined" && "randomUUID" in crypto
609
+ ? crypto.randomUUID()
610
+ : `draft-${Date.now()}-${Math.random().toString(36).slice(2)}`;
611
+ set({ pendingDraftId: id });
612
+ return id;
613
+ },
614
+
577
615
  bindDraftSession: (sessionId) =>
578
616
  set((state) => {
579
617
  const adopted = state.buffers[sessionId] ?? state.draft ?? emptyChat();
@@ -586,6 +624,7 @@ export const useChatStore = create<ChatState>((set, get) => {
586
624
  return {
587
625
  buffers,
588
626
  draft: null,
627
+ pendingDraftId: null,
589
628
  ...(followView ? { activeSessionId: sessionId } : {}),
590
629
  };
591
630
  }),
@@ -8,16 +8,38 @@ type VpnStatus =
8
8
  | "unreachable"
9
9
  | "checking";
10
10
 
11
+ /**
12
+ * The last thing that went wrong, whether or not a turn was running.
13
+ *
14
+ * Before this existed, a `error` frame was only surfaced when a message was
15
+ * mid-stream: it was appended to the streaming transcript, and outside a turn
16
+ * it went nowhere at all — no console, no store, no UI. A PARSE_ERROR between
17
+ * turns was dropped on the client as silently as it was on the server.
18
+ * Protocol-level drops from the SDK client land here too.
19
+ */
20
+ export interface ConnectionError {
21
+ code: string;
22
+ message: string;
23
+ /** Epoch millis, so a view can decide whether this is still interesting. */
24
+ at: number;
25
+ }
26
+
11
27
  interface ConnectionState {
12
28
  wsStatus: WsStatus;
13
29
  vpnStatus: VpnStatus;
30
+ lastError: ConnectionError | null;
14
31
  setWsStatus: (status: WsStatus) => void;
15
32
  setVpnStatus: (status: VpnStatus) => void;
33
+ reportError: (code: string, message: string) => void;
34
+ clearError: () => void;
16
35
  }
17
36
 
18
37
  export const useConnectionStore = create<ConnectionState>((set) => ({
19
38
  wsStatus: "disconnected",
20
39
  vpnStatus: "checking",
40
+ lastError: null,
21
41
  setWsStatus: (wsStatus) => set({ wsStatus }),
22
42
  setVpnStatus: (vpnStatus) => set({ vpnStatus }),
43
+ reportError: (code, message) => set({ lastError: { code, message, at: Date.now() } }),
44
+ clearError: () => set({ lastError: null }),
23
45
  }));
@@ -211,6 +211,11 @@ export const useFileStore = create<FileState>((set, get) => ({
211
211
 
212
212
  try {
213
213
  const content = await fetchContent(normalized);
214
+ // Drop a response the user has already navigated away from. Two rapid
215
+ // clicks race, and without this the SLOWER fetch wins: the viewer showed
216
+ // the newer file's path with the older file's content, which reads as
217
+ // corruption rather than as a stale load.
218
+ if (get().currentPath !== normalized) return;
214
219
  set({ currentContent: content, contentLoading: false });
215
220
  // Default mode: prefer preview when available; persist otherwise
216
221
  const { viewMode } = get();
@@ -226,6 +231,9 @@ export const useFileStore = create<FileState>((set, get) => ({
226
231
  if (e.status === 413 && e.size) msg = `File too large (${(e.size / 1024 / 1024).toFixed(2)} MB; cap ${(FILE_SIZE_CAP_BYTES / 1024 / 1024).toFixed(0)} MB).`;
227
232
  else if (e.status === 404) msg = "File not found.";
228
233
  else if (e.message === "invalid_path") msg = "Invalid path.";
234
+ // Same guard: a failure for a file the user already left must not
235
+ // replace the file they are now looking at with an error.
236
+ if (get().currentPath !== normalized) return;
229
237
  set({ contentError: msg, contentLoading: false });
230
238
  } finally {
231
239
  await ancestorsPromise;
@@ -204,7 +204,20 @@ function sceneRequest(state: GraphState): { endpoint: string; query: string } |
204
204
  }
205
205
 
206
206
  export const useGraphStore = create<GraphState>((set, get) => ({
207
- mode: "local",
207
+ /**
208
+ * Clusters, not Local.
209
+ *
210
+ * Local is centred on ONE node and has no center until the user picks one,
211
+ * so opening the graph landed on an empty canvas with a picker — which reads
212
+ * as "the graph is broken", not as "choose a starting point". Clusters is
213
+ * the only mode that answers the question someone opening a graph view is
214
+ * actually asking: what is in here, and what clumps together.
215
+ *
216
+ * Local stays one click away, and clicking any node still switches to it
217
+ * (node-popover.tsx) — that is the natural way in, rather than the landing
218
+ * state.
219
+ */
220
+ mode: "clusters",
208
221
  meta: null,
209
222
  metaState: "idle",
210
223
 
@@ -1,26 +0,0 @@
1
- import type { ClientMessage, ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
- type MessageHandler = (msg: ServerMessage) => void;
3
- type StatusHandler = (status: "connecting" | "connected" | "disconnected") => void;
4
- export declare class WSClient {
5
- private ws;
6
- private url;
7
- private onMessage;
8
- private onStatusChange;
9
- private reconnectAttempt;
10
- private reconnectTimer;
11
- private closed;
12
- constructor(url: string, onMessage: MessageHandler, onStatusChange: StatusHandler);
13
- connect(): void;
14
- send(msg: ClientMessage): void;
15
- close(): void;
16
- get isConnected(): boolean;
17
- /**
18
- * Skip the remaining backoff and reconnect immediately (e.g. when the
19
- * browser fires an `online` event). No-op if open/connecting or closed
20
- * deliberately.
21
- */
22
- reconnectNow(): void;
23
- private scheduleReconnect;
24
- }
25
- export {};
26
- //# sourceMappingURL=ws-client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ws-client.d.ts","sourceRoot":"","sources":["../../src/lib/ws-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEtF,KAAK,cAAc,GAAG,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;AACnD,KAAK,aAAa,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW,GAAG,cAAc,KAAK,IAAI,CAAC;AAEnF,qBAAa,QAAQ;IACnB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,SAAS,CAAiB;IAClC,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,MAAM,CAAS;gBAGrB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,cAAc,EACzB,cAAc,EAAE,aAAa;IAO/B,OAAO;IAuCP,IAAI,CAAC,GAAG,EAAE,aAAa;IAMvB,KAAK;IAUL,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED;;;;OAIG;IACH,YAAY;IAYZ,OAAO,CAAC,iBAAiB;CAQ1B"}