@schlessera/brain-ui-react 0.14.0 → 0.16.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 (73) hide show
  1. package/README.md +25 -0
  2. package/dist/components/chat/brain-markdown.js +2 -2
  3. package/dist/components/chat/brain-markdown.js.map +1 -1
  4. package/dist/components/chat/chat-page.d.ts.map +1 -1
  5. package/dist/components/chat/chat-page.js +4 -0
  6. package/dist/components/chat/chat-page.js.map +1 -1
  7. package/dist/components/files/file-viewer-binary.js +2 -2
  8. package/dist/components/files/file-viewer-binary.js.map +1 -1
  9. package/dist/components/files/file-viewer.js +2 -2
  10. package/dist/components/files/file-viewer.js.map +1 -1
  11. package/dist/components/graph/graph-page.js +3 -3
  12. package/dist/components/graph/graph-page.js.map +1 -1
  13. package/dist/components/images/mask-editor.js +2 -2
  14. package/dist/components/images/mask-editor.js.map +1 -1
  15. package/dist/config.d.ts +37 -4
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +29 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/hooks/use-websocket.d.ts +2 -2
  20. package/dist/hooks/use-websocket.d.ts.map +1 -1
  21. package/dist/hooks/use-websocket.js +49 -5
  22. package/dist/hooks/use-websocket.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/api-client.js +2 -2
  28. package/dist/lib/api-client.js.map +1 -1
  29. package/dist/lib/backend.d.ts +3 -3
  30. package/dist/lib/backend.d.ts.map +1 -1
  31. package/dist/lib/backend.js +16 -15
  32. package/dist/lib/backend.js.map +1 -1
  33. package/dist/lib/share-intake.js +2 -2
  34. package/dist/lib/share-intake.js.map +1 -1
  35. package/dist/lib/share.js +2 -2
  36. package/dist/lib/share.js.map +1 -1
  37. package/dist/stores/chat-store.d.ts +13 -0
  38. package/dist/stores/chat-store.d.ts.map +1 -1
  39. package/dist/stores/chat-store.js +36 -8
  40. package/dist/stores/chat-store.js.map +1 -1
  41. package/dist/stores/connection-store.d.ts +18 -0
  42. package/dist/stores/connection-store.d.ts.map +1 -1
  43. package/dist/stores/connection-store.js +3 -0
  44. package/dist/stores/connection-store.js.map +1 -1
  45. package/dist/stores/file-store.d.ts.map +1 -1
  46. package/dist/stores/file-store.js +15 -5
  47. package/dist/stores/file-store.js.map +1 -1
  48. package/dist/stores/graph-store.d.ts.map +1 -1
  49. package/dist/stores/graph-store.js +8 -7
  50. package/dist/stores/graph-store.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/components/chat/brain-markdown.tsx +2 -2
  53. package/src/components/chat/chat-page.tsx +4 -0
  54. package/src/components/files/file-viewer-binary.tsx +2 -2
  55. package/src/components/files/file-viewer.tsx +2 -2
  56. package/src/components/graph/graph-page.tsx +3 -3
  57. package/src/components/images/mask-editor.tsx +2 -2
  58. package/src/config.ts +63 -4
  59. package/src/hooks/use-websocket.ts +55 -6
  60. package/src/index.ts +1 -1
  61. package/src/lib/api-client.ts +2 -2
  62. package/src/lib/backend.ts +16 -15
  63. package/src/lib/share-intake.ts +2 -2
  64. package/src/lib/share.ts +2 -2
  65. package/src/stores/chat-store.ts +50 -8
  66. package/src/stores/connection-store.ts +22 -0
  67. package/src/stores/file-store.ts +13 -5
  68. package/src/stores/graph-store.ts +7 -7
  69. package/dist/lib/ws-client.d.ts +0 -26
  70. package/dist/lib/ws-client.d.ts.map +0 -1
  71. package/dist/lib/ws-client.js +0 -93
  72. package/dist/lib/ws-client.js.map +0 -1
  73. package/src/lib/ws-client.ts +0 -109
package/src/config.ts CHANGED
@@ -1,8 +1,21 @@
1
1
  /**
2
- * Deployment-tunable branding/copy. A module-level singleton, matching the
3
- * renderer/ASR registries: the shell calls `configureBrainUi()` once at boot
4
- * (before mounting), components read `uiConfig` at render time. Runtime
5
- * plugin-style reconfiguration is deliberately unsupported.
2
+ * The package's single configuration chokepoint.
3
+ *
4
+ * `ui-server` resolves its configuration once, at the edge, in `createApp()`,
5
+ * and nothing deeper in that package touches the ambient environment. This is
6
+ * the browser-side mirror of that rule: the deployment shell calls
7
+ * `configureBrainUi()` once at boot, before the first render, and every module
8
+ * below reads the resolved values instead of reaching for build-tool globals.
9
+ *
10
+ * The rule exists because a library that reads `import.meta.env` pins its
11
+ * consumers to one bundler. `VITE_BACKEND_URL` used to be read here at module
12
+ * load, so a webpack or Next.js consumer had no way to point the client at a
13
+ * split-topology backend at all, and no way to discover that from the types.
14
+ * `scripts/check-env-access.ts` now refuses `import.meta.env` anywhere in a
15
+ * package's `src`, so the loophole cannot reopen.
16
+ *
17
+ * Values are a module-level singleton, matching the renderer/ASR registries.
18
+ * Runtime plugin-style reconfiguration is deliberately unsupported.
6
19
  */
7
20
  export interface BrainUiConfig {
8
21
  /** Product name shown on the login screen and connection status. */
@@ -13,6 +26,24 @@ export interface BrainUiConfig {
13
26
  shareTitle: string;
14
27
  /** Composer placeholder. */
15
28
  composerPlaceholder: string;
29
+ /**
30
+ * Origin of the API/WebSocket backend, for a SPLIT topology (client and
31
+ * backend on different origins, e.g. a public frontend reaching its backend
32
+ * over a VPN). Empty — the default — means SAME-ORIGIN: API calls go to
33
+ * `/api` and the WebSocket derives its host from `window.location`.
34
+ *
35
+ * The shell resolves this however it likes (a Vite `VITE_*` define, a
36
+ * `<meta>` tag, a runtime fetch) and passes the result in. A trailing slash
37
+ * is stripped.
38
+ */
39
+ backendUrl: string;
40
+ /**
41
+ * Install the `window.__chatStore` / `window.__graphStore` debug handles,
42
+ * which let browser automation inject fixture messages without a live agent
43
+ * session. The shell decides what "development" means — this package must
44
+ * not infer it from a bundler's DEV flag.
45
+ */
46
+ devTools: boolean;
16
47
  }
17
48
 
18
49
  export const uiConfig: BrainUiConfig = {
@@ -20,8 +51,36 @@ export const uiConfig: BrainUiConfig = {
20
51
  assistantName: "Brain",
21
52
  shareTitle: "Shared from Brain",
22
53
  composerPlaceholder: "Ask your brain anything...",
54
+ backendUrl: "",
55
+ devTools: false,
23
56
  };
24
57
 
58
+ /**
59
+ * Dev-handle installers, registered at module scope by the stores that own a
60
+ * handle. They cannot read `uiConfig.devTools` themselves: ES imports are
61
+ * hoisted, so a store's module body runs BEFORE the shell's
62
+ * `configureBrainUi()` call. Registering instead of reading lets the flag
63
+ * arrive late and still take effect.
64
+ */
65
+ const devHandleInstallers: Array<() => void> = [];
66
+ let devHandlesInstalled = false;
67
+
68
+ function installDevHandles(): void {
69
+ if (devHandlesInstalled) return;
70
+ devHandlesInstalled = true;
71
+ for (const install of devHandleInstallers) install();
72
+ }
73
+
74
+ /** Register a debug handle to install if (and when) `devTools` is turned on. */
75
+ export function registerDevHandle(install: () => void): void {
76
+ devHandleInstallers.push(install);
77
+ if (uiConfig.devTools) install();
78
+ }
79
+
25
80
  export function configureBrainUi(overrides: Partial<BrainUiConfig>): void {
26
81
  Object.assign(uiConfig, overrides);
82
+ if (typeof overrides.backendUrl === "string") {
83
+ uiConfig.backendUrl = overrides.backendUrl.replace(/\/$/, "");
84
+ }
85
+ if (uiConfig.devTools) installDevHandles();
27
86
  }
@@ -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.
package/src/index.ts CHANGED
@@ -72,4 +72,4 @@ export { ShareIntake } from "./components/chat/share-card.js";
72
72
 
73
73
  // API surface (typed REST client + backend URL helpers).
74
74
  export { api } from "./lib/api-client.js";
75
- export { API_BASE, getWsUrl, getBackendUrl } from "./lib/backend.js";
75
+ export { apiBase, getWsUrl, getBackendUrl } from "./lib/backend.js";
@@ -1,4 +1,4 @@
1
- import { API_BASE } from "./backend.js";
1
+ import { apiBase } from "./backend.js";
2
2
  import type {
3
3
  VoiceKeytermsResponse,
4
4
  VoiceTokenResponse,
@@ -46,7 +46,7 @@ export interface BackendInfo {
46
46
  }
47
47
 
48
48
  async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
49
- const res = await fetch(`${API_BASE}${path}`, {
49
+ const res = await fetch(`${apiBase()}${path}`, {
50
50
  ...init,
51
51
  headers: {
52
52
  "Content-Type": "application/json",
@@ -1,26 +1,27 @@
1
1
  /**
2
- * Backend URL configuration.
2
+ * Where the API and WebSocket live.
3
3
  *
4
4
  * Default topology is SAME-ORIGIN: the server serves the built client and the
5
5
  * API/WS from one origin, so API calls go to "/api" and the WebSocket derives
6
- * its host from window.location. No build-time configuration is needed.
6
+ * its host from window.location. Nothing needs configuring.
7
7
  *
8
- * VITE_BACKEND_URL is optional advanced config for a SPLIT topology (client and
9
- * backend on different origins, e.g. a public frontend with the backend reached
10
- * over a VPN). When set at build time, API and WS calls target that origin.
11
- * The read is defensive — outside a Vite build (bun test, Node import of the
12
- * dist) `import.meta.env` does not exist.
8
+ * A SPLIT topology (client and backend on different origins) sets
9
+ * `backendUrl` through `configureBrainUi()`. These are functions rather than
10
+ * module constants on purpose: a constant would freeze the value at import
11
+ * time, and ES imports are hoisted, so it would always capture the default
12
+ * instead of what the shell configured.
13
13
  */
14
- const viteEnv = (import.meta as { env?: Record<string, string | undefined> }).env;
15
- const BACKEND_URL = viteEnv?.VITE_BACKEND_URL?.replace(/\/$/, "") ?? "";
14
+ import { uiConfig } from "../config.js";
16
15
 
17
- /** Base URL for API calls. Empty BACKEND_URL = same-origin "/api". */
18
- export const API_BASE = `${BACKEND_URL}/api`;
16
+ /** Base URL for API calls. Empty backendUrl = same-origin "/api". */
17
+ export function apiBase(): string {
18
+ return `${uiConfig.backendUrl}/api`;
19
+ }
19
20
 
20
- /** WebSocket URL. Derives wss/ws + host from BACKEND_URL, else same-origin. */
21
+ /** WebSocket URL. Derives wss/ws + host from backendUrl, else same-origin. */
21
22
  export function getWsUrl(): string {
22
- if (BACKEND_URL) {
23
- const url = new URL("/ws", BACKEND_URL);
23
+ if (uiConfig.backendUrl) {
24
+ const url = new URL("/ws", uiConfig.backendUrl);
24
25
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
25
26
  return url.toString();
26
27
  }
@@ -31,5 +32,5 @@ export function getWsUrl(): string {
31
32
 
32
33
  /** Base URL for direct fetch calls (streaming endpoints). */
33
34
  export function getBackendUrl(path: string): string {
34
- return `${BACKEND_URL}${path}`;
35
+ return `${uiConfig.backendUrl}${path}`;
35
36
  }
@@ -4,7 +4,7 @@ import {
4
4
  type ShareIntakeResult,
5
5
  } from "@schlessera/brain-ui-sdk/protocol";
6
6
  import type { StoredShare } from "@schlessera/brain-ui-sdk/share-target";
7
- import { API_BASE } from "./backend.js";
7
+ import { apiBase } from "./backend.js";
8
8
  import {
9
9
  fileToAttachment,
10
10
  validateAttachments,
@@ -85,7 +85,7 @@ export async function uploadShare(
85
85
 
86
86
  let response: Response;
87
87
  try {
88
- response = await fetchImpl(`${API_BASE}/share`, {
88
+ response = await fetchImpl(`${apiBase()}/share`, {
89
89
  method: "POST",
90
90
  body: form,
91
91
  });
package/src/lib/share.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { API_BASE } from "./backend.js";
1
+ import { apiBase } from "./backend.js";
2
2
  import type { RenderRequest } from "@schlessera/brain-ui-sdk/protocol";
3
3
 
4
4
  export type ShareKind = "file" | "text" | "richtext";
@@ -125,7 +125,7 @@ export async function renderToFile(
125
125
  req: RenderRequest,
126
126
  filename: string
127
127
  ): Promise<File> {
128
- const res = await fetch(`${API_BASE}/render`, {
128
+ const res = await fetch(`${apiBase()}/render`, {
129
129
  method: "POST",
130
130
  headers: { "Content-Type": "application/json" },
131
131
  body: JSON.stringify(req),
@@ -6,6 +6,7 @@ import type {
6
6
  AskUserAnnotation,
7
7
  } from "@schlessera/brain-ui-sdk/protocol";
8
8
  import { useProviderStore } from "./provider-store.js";
9
+ import { registerDevHandle } from "../config.js";
9
10
 
10
11
  export type { MessagePart };
11
12
 
@@ -102,6 +103,17 @@ interface ChatState {
102
103
  buffers: Record<string, SessionChat>;
103
104
  /** The unbound new-conversation buffer, if one is in progress. */
104
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;
105
117
  /** The session in view; null = the draft / new-chat view. */
106
118
  activeSessionId: string | null;
107
119
 
@@ -167,6 +179,8 @@ interface ChatState {
167
179
  * If the draft view is active, the view follows. No-op draft = empty buffer.
168
180
  */
169
181
  bindDraftSession: (sessionId: string) => void;
182
+ /** Mint and remember the correlation id for a draft turn about to be sent. */
183
+ startDraftTurn: () => string;
170
184
  /** Switch the view to a session (creating an empty buffer if none), or to the draft (null). */
171
185
  setActiveSession: (sessionId: string | null) => void;
172
186
  /** New chat: drop the draft, unbind the view, unpin the provider. */
@@ -301,10 +315,25 @@ export const useChatStore = create<ChatState>((set, get) => {
301
315
  ): void {
302
316
  mutateBuffer(key, (chat) => {
303
317
  const msgs = [...chat.messages];
304
- 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
+ }
305
334
  let out: Partial<SessionChat> = {};
306
- if (last?.role === "assistant") {
307
- msgs[msgs.length - 1] = fn(last);
335
+ if (index !== -1) {
336
+ msgs[index] = fn(msgs[index]);
308
337
  out = { messages: msgs };
309
338
  }
310
339
  return extra ? { ...out, ...extra(chat) } : out;
@@ -314,6 +343,7 @@ export const useChatStore = create<ChatState>((set, get) => {
314
343
  return {
315
344
  buffers: {},
316
345
  draft: null,
346
+ pendingDraftId: null,
317
347
  // localStorage (not sessionStorage) so the active session id survives the
318
348
  // PWA process being killed on mobile — that durability is what lets a cold
319
349
  // relaunch re-request the full transcript instead of showing nothing.
@@ -573,6 +603,15 @@ export const useChatStore = create<ChatState>((set, get) => {
573
603
  };
574
604
  }),
575
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
+
576
615
  bindDraftSession: (sessionId) =>
577
616
  set((state) => {
578
617
  const adopted = state.buffers[sessionId] ?? state.draft ?? emptyChat();
@@ -585,6 +624,7 @@ export const useChatStore = create<ChatState>((set, get) => {
585
624
  return {
586
625
  buffers,
587
626
  draft: null,
627
+ pendingDraftId: null,
588
628
  ...(followView ? { activeSessionId: sessionId } : {}),
589
629
  };
590
630
  }),
@@ -631,10 +671,12 @@ export const useChatStore = create<ChatState>((set, get) => {
631
671
  });
632
672
 
633
673
  // Dev-only handle so browser automation / manual debugging can inject
634
- // fixture messages without a live Claude session. The env read is defensive:
635
- // outside a Vite build `import.meta.env` does not exist.
636
- const devEnv = (import.meta as { env?: Record<string, unknown> }).env;
637
- if (typeof window !== "undefined" && devEnv?.DEV) {
674
+ // fixture messages without a live Claude session. Registered rather than
675
+ // installed: whether this is a development build is the shell's call
676
+ // (`configureBrainUi({ devTools: true })`), not something a component library
677
+ // infers from its bundler.
678
+ registerDevHandle(() => {
679
+ if (typeof window === "undefined") return;
638
680
  (window as unknown as { __chatStore?: typeof useChatStore }).__chatStore =
639
681
  useChatStore;
640
- }
682
+ });
@@ -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
  }));
@@ -6,7 +6,7 @@ import type {
6
6
  WikilinkMapResponse,
7
7
  } from "@schlessera/brain-ui-sdk/protocol";
8
8
  import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
9
- import { API_BASE } from "../lib/backend.js";
9
+ import { apiBase } from "../lib/backend.js";
10
10
  import { isMermaidPath } from "../lib/mermaid.js";
11
11
 
12
12
  export type ViewMode = "preview" | "raw";
@@ -76,7 +76,7 @@ interface FileState {
76
76
  }
77
77
 
78
78
  async function fetchTree(path: string): Promise<FileEntry[]> {
79
- const url = `${API_BASE}/files/tree${path ? `?path=${encodeURIComponent(path)}` : ""}`;
79
+ const url = `${apiBase()}/files/tree${path ? `?path=${encodeURIComponent(path)}` : ""}`;
80
80
  const res = await fetch(url);
81
81
  if (!res.ok) {
82
82
  const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -87,7 +87,7 @@ async function fetchTree(path: string): Promise<FileEntry[]> {
87
87
  }
88
88
 
89
89
  async function fetchContent(path: string): Promise<FileContentResponse> {
90
- const res = await fetch(`${API_BASE}/files/content?path=${encodeURIComponent(path)}`);
90
+ const res = await fetch(`${apiBase()}/files/content?path=${encodeURIComponent(path)}`);
91
91
  if (!res.ok) {
92
92
  const body = await res.json().catch(() => ({ error: res.statusText }));
93
93
  const err = new Error(body.error || `HTTP ${res.status}`);
@@ -99,7 +99,7 @@ async function fetchContent(path: string): Promise<FileContentResponse> {
99
99
  }
100
100
 
101
101
  async function fetchResolve(path: string): Promise<FileResolveResponse> {
102
- const res = await fetch(`${API_BASE}/files/resolve?path=${encodeURIComponent(path)}`);
102
+ const res = await fetch(`${apiBase()}/files/resolve?path=${encodeURIComponent(path)}`);
103
103
  if (!res.ok) {
104
104
  const body = await res.json().catch(() => ({ error: res.statusText }));
105
105
  throw new Error(body.error || `HTTP ${res.status}`);
@@ -108,7 +108,7 @@ async function fetchResolve(path: string): Promise<FileResolveResponse> {
108
108
  }
109
109
 
110
110
  async function fetchWikilinks(): Promise<WikilinkMapResponse> {
111
- const res = await fetch(`${API_BASE}/files/wikilinks`);
111
+ const res = await fetch(`${apiBase()}/files/wikilinks`);
112
112
  if (!res.ok) {
113
113
  const body = await res.json().catch(() => ({ error: res.statusText }));
114
114
  throw new Error(body.error || `HTTP ${res.status}`);
@@ -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;
@@ -4,7 +4,8 @@ import type {
4
4
  GraphSubgraphResponse,
5
5
  GraphMaintenanceResponse,
6
6
  } from "@schlessera/brain-ui-sdk/protocol";
7
- import { API_BASE } from "../lib/backend.js";
7
+ import { apiBase } from "../lib/backend.js";
8
+ import { registerDevHandle } from "../config.js";
8
9
  import { buildQuery, mergeSubgraphs } from "../components/graph/lib/graph-helpers.js";
9
10
 
10
11
  export type GraphMode = "clusters" | "discovery" | "local" | "maintenance";
@@ -135,7 +136,7 @@ function cachePut(key: string, value: GraphSubgraphResponse | GraphMaintenanceRe
135
136
  }
136
137
 
137
138
  async function fetchGraphJson<T>(pathAndQuery: string): Promise<T> {
138
- const res = await fetch(`${API_BASE}/graph${pathAndQuery}`);
139
+ const res = await fetch(`${apiBase()}/graph${pathAndQuery}`);
139
140
  if (!res.ok) {
140
141
  const body = await res.json().catch(() => ({}) as Record<string, unknown>);
141
142
  const err = new Error(
@@ -336,10 +337,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
336
337
  }));
337
338
 
338
339
  // Dev-only handle for exercising the view with injected fixtures (the
339
- // window.__chatStore precedent). The env read is defensive: outside a Vite
340
- // build `import.meta.env` does not exist.
341
- const devEnv = (import.meta as { env?: Record<string, unknown> }).env;
342
- if (typeof window !== "undefined" && devEnv?.DEV) {
340
+ // window.__chatStore precedent), gated on the shell's `devTools` flag.
341
+ registerDevHandle(() => {
342
+ if (typeof window === "undefined") return;
343
343
  (window as unknown as { __graphStore?: typeof useGraphStore }).__graphStore =
344
344
  useGraphStore;
345
- }
345
+ });
@@ -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"}