@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.
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +4 -0
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/risk-hints.js +1 -1
- package/dist/components/chat/risk-hints.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +2 -19
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +3 -125
- package/dist/components/chat/tool-views.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +2 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +49 -5
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/lib/diff.d.ts +19 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +129 -0
- package/dist/lib/diff.js.map +1 -0
- package/dist/stores/chat-store.d.ts +13 -0
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +28 -4
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +18 -0
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +3 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/stores/file-store.d.ts.map +1 -1
- package/dist/stores/file-store.js +10 -0
- package/dist/stores/file-store.js.map +1 -1
- package/dist/stores/graph-store.d.ts.map +1 -1
- package/dist/stores/graph-store.js +14 -1
- package/dist/stores/graph-store.js.map +1 -1
- package/dist/theme.css +435 -0
- package/package.json +14 -8
- package/src/components/chat/chat-page.tsx +4 -0
- package/src/components/chat/risk-hints.ts +1 -1
- package/src/components/chat/tool-views.tsx +4 -137
- package/src/hooks/use-websocket.ts +55 -6
- package/src/lib/diff.ts +140 -0
- package/src/stores/chat-store.ts +43 -4
- package/src/stores/connection-store.ts +22 -0
- package/src/stores/file-store.ts +8 -0
- package/src/stores/graph-store.ts +14 -1
- package/dist/lib/ws-client.d.ts +0 -26
- package/dist/lib/ws-client.d.ts.map +0 -1
- package/dist/lib/ws-client.js +0 -93
- package/dist/lib/ws-client.js.map +0 -1
- 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 {
|
|
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 && (
|
|
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:
|
|
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
|
|
397
|
-
*
|
|
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
|
|
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.
|
package/src/lib/diff.ts
ADDED
|
@@ -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
|
+
}
|
package/src/stores/chat-store.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
308
|
-
msgs[
|
|
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,
|
|
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
|
}));
|
package/src/stores/file-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/dist/lib/ws-client.d.ts
DELETED
|
@@ -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"}
|