@schlessera/brain-ui-react 0.20.0 → 0.23.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 (66) hide show
  1. package/dist/components/activity/activity-page.d.ts.map +1 -1
  2. package/dist/components/activity/activity-page.js +49 -10
  3. package/dist/components/activity/activity-page.js.map +1 -1
  4. package/dist/components/activity/span-bits.d.ts +30 -3
  5. package/dist/components/activity/span-bits.d.ts.map +1 -1
  6. package/dist/components/activity/span-bits.js +59 -6
  7. package/dist/components/activity/span-bits.js.map +1 -1
  8. package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -1
  9. package/dist/components/chat/renderers/claude-tools.js +21 -1
  10. package/dist/components/chat/renderers/claude-tools.js.map +1 -1
  11. package/dist/components/chat/renderers/index.d.ts.map +1 -1
  12. package/dist/components/chat/renderers/index.js +2 -0
  13. package/dist/components/chat/renderers/index.js.map +1 -1
  14. package/dist/components/chat/renderers/pi-tools.d.ts +3 -0
  15. package/dist/components/chat/renderers/pi-tools.d.ts.map +1 -0
  16. package/dist/components/chat/renderers/pi-tools.js +112 -0
  17. package/dist/components/chat/renderers/pi-tools.js.map +1 -0
  18. package/dist/components/chat/risk-hints.d.ts +14 -2
  19. package/dist/components/chat/risk-hints.d.ts.map +1 -1
  20. package/dist/components/chat/risk-hints.js +54 -19
  21. package/dist/components/chat/risk-hints.js.map +1 -1
  22. package/dist/components/chat/tool-call-timeline.d.ts +1 -1
  23. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  24. package/dist/components/chat/tool-call-timeline.js +25 -14
  25. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  26. package/dist/components/settings/models-tab.d.ts.map +1 -1
  27. package/dist/components/settings/models-tab.js +2 -1
  28. package/dist/components/settings/models-tab.js.map +1 -1
  29. package/dist/components/settings/pi-accounts.d.ts +13 -0
  30. package/dist/components/settings/pi-accounts.d.ts.map +1 -0
  31. package/dist/components/settings/pi-accounts.js +146 -0
  32. package/dist/components/settings/pi-accounts.js.map +1 -0
  33. package/dist/hooks/use-websocket.d.ts.map +1 -1
  34. package/dist/hooks/use-websocket.js +26 -2
  35. package/dist/hooks/use-websocket.js.map +1 -1
  36. package/dist/lib/api-client.d.ts +42 -0
  37. package/dist/lib/api-client.d.ts.map +1 -1
  38. package/dist/lib/api-client.js +18 -0
  39. package/dist/lib/api-client.js.map +1 -1
  40. package/dist/lib/tool-names.d.ts.map +1 -1
  41. package/dist/lib/tool-names.js +5 -1
  42. package/dist/lib/tool-names.js.map +1 -1
  43. package/dist/stores/activity-store.d.ts +10 -0
  44. package/dist/stores/activity-store.d.ts.map +1 -1
  45. package/dist/stores/activity-store.js +34 -1
  46. package/dist/stores/activity-store.js.map +1 -1
  47. package/dist/stores/chat-store.d.ts +8 -0
  48. package/dist/stores/chat-store.d.ts.map +1 -1
  49. package/dist/stores/chat-store.js +4 -0
  50. package/dist/stores/chat-store.js.map +1 -1
  51. package/dist/styles.css +1 -1
  52. package/package.json +2 -2
  53. package/src/components/activity/activity-page.tsx +168 -21
  54. package/src/components/activity/span-bits.tsx +74 -17
  55. package/src/components/chat/renderers/claude-tools.tsx +32 -1
  56. package/src/components/chat/renderers/index.ts +2 -0
  57. package/src/components/chat/renderers/pi-tools.tsx +144 -0
  58. package/src/components/chat/risk-hints.ts +73 -25
  59. package/src/components/chat/tool-call-timeline.tsx +57 -16
  60. package/src/components/settings/models-tab.tsx +3 -0
  61. package/src/components/settings/pi-accounts.tsx +258 -0
  62. package/src/hooks/use-websocket.ts +29 -2
  63. package/src/lib/api-client.ts +52 -0
  64. package/src/lib/tool-names.ts +6 -1
  65. package/src/stores/activity-store.ts +36 -3
  66. package/src/stores/chat-store.ts +16 -0
@@ -0,0 +1,258 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Check, ExternalLink, Loader2, LogOut, X } from "lucide-react";
3
+ import { api, type PiAuthProviderStatus, type PiLoginFlow } from "../../lib/api-client.js";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ /**
7
+ * Provider sign-in for the pi backend's OAuth vendors — most importantly
8
+ * OpenAI (ChatGPT Plus/Pro), whose device-code flow needs no browser callback
9
+ * on the server: the user gets a short code here, enters it at the provider's
10
+ * verification page on ANY device, and the server stores the credential.
11
+ *
12
+ * Renders nothing when the server reports no pi providers (pi not
13
+ * configured), so the Models tab is unchanged for Claude-only deployments.
14
+ */
15
+ export function PiAccountsSection({ active }: { active: boolean }) {
16
+ const [providers, setProviders] = useState<PiAuthProviderStatus[]>([]);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [busy, setBusy] = useState<string | null>(null);
19
+ const [flow, setFlow] = useState<PiLoginFlow | null>(null);
20
+ const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
21
+
22
+ function stopPolling() {
23
+ if (pollTimer.current) {
24
+ clearTimeout(pollTimer.current);
25
+ pollTimer.current = null;
26
+ }
27
+ }
28
+
29
+ async function reload() {
30
+ try {
31
+ const { providers } = await api.piAuthProviders();
32
+ setProviders(providers);
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : "Could not load accounts");
35
+ }
36
+ }
37
+
38
+ useEffect(() => {
39
+ if (!active) return;
40
+ let cancelled = false;
41
+ api
42
+ .piAuthProviders()
43
+ .then(({ providers }) => {
44
+ if (!cancelled) setProviders(providers);
45
+ })
46
+ .catch(() => {
47
+ // A server without the endpoint (older release) just hides the card.
48
+ if (!cancelled) setProviders([]);
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [active]);
54
+
55
+ // Poll the pending flow until it settles. Chained timeouts rather than an
56
+ // interval, so a slow response never stacks requests.
57
+ useEffect(() => {
58
+ if (!flow || flow.status !== "pending") return;
59
+ let disposed = false;
60
+ const delayMs = (flow.intervalSeconds ?? 5) * 1000;
61
+ const tick = async () => {
62
+ try {
63
+ const { flow: next } = await api.piAuthFlow(flow.id);
64
+ if (disposed) return;
65
+ setFlow(next);
66
+ if (next.status === "success") void reload();
67
+ } catch {
68
+ if (disposed) return;
69
+ // Transient poll failure: keep trying until the flow expires.
70
+ pollTimer.current = setTimeout(tick, delayMs);
71
+ return;
72
+ }
73
+ };
74
+ pollTimer.current = setTimeout(tick, delayMs);
75
+ return () => {
76
+ disposed = true;
77
+ stopPolling();
78
+ };
79
+ }, [flow]);
80
+
81
+ async function connect(providerId: string) {
82
+ setBusy(providerId);
83
+ setError(null);
84
+ try {
85
+ const { flow } = await api.piAuthStart(providerId);
86
+ setFlow(flow);
87
+ if (flow.status === "error") setError(flow.error ?? "Login failed");
88
+ } catch (err) {
89
+ setError(err instanceof Error ? err.message : "Could not start login");
90
+ } finally {
91
+ setBusy(null);
92
+ }
93
+ }
94
+
95
+ async function cancel() {
96
+ if (!flow) return;
97
+ stopPolling();
98
+ try {
99
+ await api.piAuthCancel(flow.id);
100
+ } catch {
101
+ // The flow record may already be gone; clearing locally is enough.
102
+ }
103
+ setFlow(null);
104
+ }
105
+
106
+ async function disconnect(providerId: string) {
107
+ setBusy(providerId);
108
+ setError(null);
109
+ try {
110
+ await api.piAuthLogout(providerId);
111
+ await reload();
112
+ } catch (err) {
113
+ setError(err instanceof Error ? err.message : "Could not disconnect");
114
+ } finally {
115
+ setBusy(null);
116
+ }
117
+ }
118
+
119
+ if (providers.length === 0) return null;
120
+
121
+ return (
122
+ <div className="mt-6">
123
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
124
+ Accounts
125
+ </h3>
126
+ <p className="mt-1 text-xs text-muted-foreground">
127
+ Model providers that sign in with an account instead of an API key.
128
+ </p>
129
+ <ul className="mt-4 flex flex-col gap-2">
130
+ {providers.map((provider) => (
131
+ <li
132
+ key={provider.providerId}
133
+ className="rounded-lg border border-border-subtle bg-surface p-3"
134
+ >
135
+ <div className="flex items-center gap-3">
136
+ <div className="min-w-0 flex-1">
137
+ <p className="truncate text-sm text-foreground">{provider.name}</p>
138
+ <p className="truncate text-[11px] text-muted-foreground">
139
+ {provider.configured
140
+ ? `Connected${provider.source ? ` · ${provider.source}` : ""}`
141
+ : "Not connected"}
142
+ </p>
143
+ </div>
144
+ {provider.configured && (
145
+ <Check className="h-4 w-4 shrink-0 text-accent" />
146
+ )}
147
+ {provider.oauth && !provider.configured && (
148
+ <button
149
+ onClick={() => connect(provider.providerId)}
150
+ disabled={busy !== null || flow?.status === "pending"}
151
+ className="shrink-0 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
152
+ >
153
+ {busy === provider.providerId ? (
154
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
155
+ ) : (
156
+ "Connect"
157
+ )}
158
+ </button>
159
+ )}
160
+ {provider.configured && provider.source === "stored" && (
161
+ <button
162
+ onClick={() => disconnect(provider.providerId)}
163
+ disabled={busy !== null}
164
+ title="Disconnect"
165
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-raised hover:text-destructive"
166
+ >
167
+ <LogOut className="h-4 w-4" />
168
+ </button>
169
+ )}
170
+ </div>
171
+
172
+ {flow && flow.providerId === provider.providerId && (
173
+ <LoginFlowCard flow={flow} onCancel={cancel} onDismiss={() => setFlow(null)} />
174
+ )}
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ {error && (
179
+ <p role="alert" className="mt-3 text-xs text-destructive">
180
+ {error}
181
+ </p>
182
+ )}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function LoginFlowCard({
188
+ flow,
189
+ onCancel,
190
+ onDismiss,
191
+ }: {
192
+ flow: PiLoginFlow;
193
+ onCancel: () => void;
194
+ onDismiss: () => void;
195
+ }) {
196
+ if (flow.status === "pending") {
197
+ return (
198
+ <div className="mt-3 rounded-lg border border-primary/40 bg-primary/5 p-3">
199
+ <p className="text-xs text-muted-foreground">
200
+ Enter this code at the provider's device page — on this or any other
201
+ device:
202
+ </p>
203
+ <p className="mt-2 select-all text-center font-[family-name:var(--font-mono)] text-xl font-semibold tracking-widest text-foreground">
204
+ {flow.userCode ?? "…"}
205
+ </p>
206
+ <div className="mt-3 flex items-center justify-center gap-2">
207
+ {flow.verificationUri && (
208
+ <a
209
+ href={flow.verificationUri}
210
+ target="_blank"
211
+ rel="noreferrer"
212
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110"
213
+ >
214
+ <ExternalLink className="h-3 w-3" />
215
+ Open verification page
216
+ </a>
217
+ )}
218
+ <button
219
+ onClick={onCancel}
220
+ className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
221
+ >
222
+ <X className="h-3 w-3" />
223
+ Cancel
224
+ </button>
225
+ </div>
226
+ <p className="mt-2 flex items-center justify-center gap-1.5 text-center text-[11px] text-muted-foreground">
227
+ <Loader2 className="h-3 w-3 animate-spin" />
228
+ Waiting for approval…
229
+ </p>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ const message =
235
+ flow.status === "success"
236
+ ? "Connected."
237
+ : flow.status === "cancelled"
238
+ ? "Login cancelled."
239
+ : flow.error ?? "Login failed.";
240
+ return (
241
+ <div
242
+ className={cn(
243
+ "mt-3 flex items-center justify-between rounded-lg border p-3 text-xs",
244
+ flow.status === "success"
245
+ ? "border-border-subtle text-muted-foreground"
246
+ : "border-destructive/30 text-destructive"
247
+ )}
248
+ >
249
+ <span>{message}</span>
250
+ <button
251
+ onClick={onDismiss}
252
+ className="text-muted-foreground transition-colors hover:text-foreground"
253
+ >
254
+ <X className="h-3.5 w-3.5" />
255
+ </button>
256
+ </div>
257
+ );
258
+ }
@@ -60,6 +60,15 @@ function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
60
60
  * payload, so an answered question survives session resume (rendered in its
61
61
  * chronological slot, collapsed) instead of vanishing.
62
62
  */
63
+ function isBareAnswersMap(v: unknown): v is Record<string, string> {
64
+ return (
65
+ !!v &&
66
+ typeof v === "object" &&
67
+ !Array.isArray(v) &&
68
+ Object.values(v).every((x) => typeof x === "string")
69
+ );
70
+ }
71
+
63
72
  function reconstructAskUserExchanges(
64
73
  toolCalls: SessionHistoryMessage["toolCalls"]
65
74
  ): AskUserExchange[] | undefined {
@@ -74,8 +83,14 @@ function reconstructAskUserExchanges(
74
83
  answers?: Record<string, string>;
75
84
  annotations?: AskUserExchange["annotations"];
76
85
  };
77
- exchange.answers = payload.answers;
78
- exchange.annotations = payload.annotations;
86
+ if (payload && typeof payload === "object" && payload.answers) {
87
+ exchange.answers = payload.answers;
88
+ exchange.annotations = payload.annotations;
89
+ } else if (isBareAnswersMap(payload)) {
90
+ // The pi backend persists the bare answers map, without the
91
+ // `{answers}` envelope the Claude tool writes.
92
+ exchange.answers = payload;
93
+ }
79
94
  } catch {
80
95
  // Non-JSON output means the question was dismissed or errored out.
81
96
  exchange.cancelled = true;
@@ -281,6 +296,18 @@ export function handleServerMessage(msg: ServerMessage) {
281
296
 
282
297
  case "session_info": {
283
298
  ensureActivitySubscription(msg.sessionId);
299
+ // Record backend ownership for renderer scoping — for ANY session, since
300
+ // background sessions keep their own transcript buffers. Older servers
301
+ // omit backendId; derive it from the pinned profile when still possible
302
+ // (fails only for since-hidden profiles, which then use the default).
303
+ const ownerBackendId =
304
+ msg.backendId ??
305
+ useProviderStore
306
+ .getState()
307
+ .available.find((p) => p.id === msg.providerId)?.backendId;
308
+ if (ownerBackendId) {
309
+ useChatStore.getState().setSessionBackend(msg.sessionId, ownerBackendId);
310
+ }
284
311
  // bindDraftSession above handled draft adoption; an info frame may still
285
312
  // re-pin the provider picker when it concerns the session in view.
286
313
  const current = useChatStore.getState();
@@ -80,6 +80,30 @@ export interface BackendInfo {
80
80
  };
81
81
  }
82
82
 
83
+ /** Auth status of one configured pi provider (mirror of the server view). */
84
+ export interface PiAuthProviderStatus {
85
+ providerId: string;
86
+ /** Human label for the credential, e.g. "OpenAI (ChatGPT Plus/Pro)". */
87
+ name: string;
88
+ configured: boolean;
89
+ source?: string;
90
+ /** Whether this provider can be signed in through the UI. */
91
+ oauth: boolean;
92
+ }
93
+
94
+ /** One OAuth device-code login flow (mirror of the server view). */
95
+ export interface PiLoginFlow {
96
+ id: string;
97
+ providerId: string;
98
+ status: "pending" | "success" | "error" | "cancelled";
99
+ userCode?: string;
100
+ verificationUri?: string;
101
+ intervalSeconds?: number;
102
+ expiresInSeconds?: number;
103
+ error?: string;
104
+ startedAt: number;
105
+ }
106
+
83
107
  async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
84
108
  const res = await fetch(`${apiBase()}${path}`, {
85
109
  ...init,
@@ -209,6 +233,34 @@ export const api = {
209
233
  /** Pricing-table freshness for the Activity staleness indicator (see `PricingState`). */
210
234
  pricingState: () => fetchJson<PricingState>("/models/pricing"),
211
235
 
236
+ /** Auth status of configured pi providers; empty when pi is not in play. */
237
+ piAuthProviders: () =>
238
+ fetchJson<{ providers: PiAuthProviderStatus[] }>("/pi-auth/providers"),
239
+
240
+ /** Start an OAuth device-code login; resolves once the user code exists. */
241
+ piAuthStart: (providerId: string) =>
242
+ fetchJson<{ flow: PiLoginFlow }>("/pi-auth/login", {
243
+ method: "POST",
244
+ body: JSON.stringify({ providerId }),
245
+ }),
246
+
247
+ /** Poll one login flow. */
248
+ piAuthFlow: (id: string) =>
249
+ fetchJson<{ flow: PiLoginFlow }>(`/pi-auth/login/${encodeURIComponent(id)}`),
250
+
251
+ /** Abort a pending login flow. */
252
+ piAuthCancel: (id: string) =>
253
+ fetchJson<{ ok: boolean }>(`/pi-auth/login/${encodeURIComponent(id)}`, {
254
+ method: "DELETE",
255
+ }),
256
+
257
+ /** Remove the stored credential for a pi provider. */
258
+ piAuthLogout: (providerId: string) =>
259
+ fetchJson<{ ok: boolean }>("/pi-auth/logout", {
260
+ method: "POST",
261
+ body: JSON.stringify({ providerId }),
262
+ }),
263
+
212
264
  /** Force a discovery refresh, bypassing the TTL. */
213
265
  refreshModels: () =>
214
266
  fetchJson<ModelCatalogResponse>("/models/refresh", { method: "POST" }),
@@ -21,7 +21,12 @@ export function normalizeToolName(name: string): string {
21
21
  : name;
22
22
  }
23
23
 
24
+ // The pi backend registers its ask-user tool under the bare name.
25
+ const PI_ASK_USER_TOOL_NAME = "ask_user";
26
+
24
27
  export const isAskUserTool = (name: string | undefined): boolean =>
25
- !!name && normalizeToolName(name) === ASK_USER_TOOL_NAME;
28
+ !!name &&
29
+ (normalizeToolName(name) === ASK_USER_TOOL_NAME ||
30
+ name === PI_ASK_USER_TOOL_NAME);
26
31
  export const isLocationTool = (name: string | undefined): boolean =>
27
32
  !!name && normalizeToolName(name) === GET_LOCATION_TOOL_NAME;
@@ -407,6 +407,11 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
407
407
  return state.events[spanId] ?? EMPTY_EVENTS;
408
408
  }
409
409
 
410
+ /** Payload event types the tool expander owns — every OTHER type belongs to
411
+ * the narrative stream (`narrativeEventsFor`) so nothing recorded is
412
+ * rendered nowhere. */
413
+ const TOOL_PAYLOAD_TYPES = new Set(["tool_input", "tool_output"]);
414
+
410
415
  /**
411
416
  * The recorded input/output payload events of a tool span (AE7). Returns a
412
417
  * fresh array per call — subscribe through `useShallow` (like `childSpans`).
@@ -414,12 +419,40 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
414
419
  export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
415
420
  const events = state.events[spanId];
416
421
  if (!events) return EMPTY_EVENTS;
417
- const payloads = events.filter(
418
- (e) => e.eventType === "tool_input" || e.eventType === "tool_output"
419
- );
422
+ const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
420
423
  return payloads.length > 0 ? payloads : EMPTY_EVENTS;
421
424
  }
422
425
 
426
+ /**
427
+ * Everything recorded against a span that is NOT a tool input/output payload:
428
+ * transcript excerpts, job output, and any span-sink event type a producer
429
+ * invents. Rendered as the span's narrative so an unknown type degrades to a
430
+ * labelled block rather than to invisibility.
431
+ */
432
+ export function narrativeEventsFor(
433
+ state: ActivityState,
434
+ spanId: string
435
+ ): ActivitySpanEvent[] {
436
+ const events = state.events[spanId];
437
+ if (!events) return EMPTY_EVENTS;
438
+ const rest = events.filter((e) => !TOOL_PAYLOAD_TYPES.has(e.eventType));
439
+ return rest.length > 0 ? rest : EMPTY_EVENTS;
440
+ }
441
+
442
+ /** Every event recorded under a run, ordered by time — the run detail's
443
+ * narrative stream and the raw-trace dump both read through this. */
444
+ export function runEvents(state: ActivityState, runId: string): ActivitySpanEvent[] {
445
+ const byId = state.spans[runId];
446
+ if (!byId) return EMPTY_EVENTS;
447
+ const out: ActivitySpanEvent[] = [];
448
+ for (const spanId of Object.keys(byId)) {
449
+ const events = state.events[spanId];
450
+ if (events) out.push(...events);
451
+ }
452
+ if (out.length === 0) return EMPTY_EVENTS;
453
+ return out.sort((a, b) => a.ts - b.ts || a.eventIndex - b.eventIndex);
454
+ }
455
+
423
456
  /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
424
457
  export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
425
458
  const runId = state.spanRun[toolUseId];
@@ -123,6 +123,12 @@ interface ChatState {
123
123
  * absence of a running/queued turn.
124
124
  */
125
125
  runStates: Record<string, "streaming" | "queued" | "idle">;
126
+ /**
127
+ * Backend that owns each session, from `session_info` (or the session list
128
+ * for history sessions). Scopes tool-call renderer resolution per backend;
129
+ * absence falls back to the deployment default.
130
+ */
131
+ backendIds: Record<string, string>;
126
132
  /**
127
133
  * Per-session note attached to a `queued` status — the host sends one once a
128
134
  * session's follow-up queue grows heavy. Cleared when the session leaves the
@@ -190,6 +196,8 @@ interface ChatState {
190
196
  state: "streaming" | "queued" | "idle",
191
197
  note?: string
192
198
  ) => void;
199
+ /** Record which backend owns a session (idempotent). */
200
+ setSessionBackend: (sessionId: string, backendId: string) => void;
193
201
  }
194
202
 
195
203
  let messageCounter = 0;
@@ -350,6 +358,7 @@ export const useChatStore = create<ChatState>((set, get) => {
350
358
  activeSessionId: readPersistedSessionId(),
351
359
  runStates: {},
352
360
  queueNotes: {},
361
+ backendIds: {},
353
362
 
354
363
  // createIfMissing: a user-initiated send must never be dropped, even when
355
364
  // the active session's buffer hasn't been materialized yet (cold start
@@ -659,6 +668,13 @@ export const useChatStore = create<ChatState>((set, get) => {
659
668
  return { runStates, queueNotes };
660
669
  }),
661
670
 
671
+ setSessionBackend: (sessionId, backendId) =>
672
+ set((state) =>
673
+ state.backendIds[sessionId] === backendId
674
+ ? state
675
+ : { backendIds: { ...state.backendIds, [sessionId]: backendId } }
676
+ ),
677
+
662
678
  clearMessages: () => {
663
679
  const state = get();
664
680
  if (state.draft) revokeAttachmentUrls(state.draft.messages);