From 3f9ae470b472026e9281c29badf253365834e064 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 11:47:57 -0700 Subject: [PATCH 1/5] sessionBindingSync: announce the app-wide model selection across windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 (provider QA, 2026-09-10): the per-chat binding already crossed windows on `biorouter:session-binding`, but the app-wide selection — BIOROUTER_PROVIDER / BIOROUTER_MODEL, the pair `/agent/start` binds a new chat to — had no announcement at all. The module header claimed each window "picks it up from its own config read"; each window read it once, at mount. Add `announceAppModelSelection` / `subscribeAppModelSelectionChanges` on the same channel, told apart from a binding by shape. The message is a nudge with no provider and no model: two windows' writes can be announced in the opposite order from the one they landed in, so a receiver must re-read the daemon rather than apply a payload. Local listeners run synchronously so the writing window re-reads too. --- .../src/utils/sessionBindingSync.test.ts | 121 +++++++++++++++++- ui/desktop/src/utils/sessionBindingSync.ts | 91 ++++++++++++- 2 files changed, 206 insertions(+), 6 deletions(-) diff --git a/ui/desktop/src/utils/sessionBindingSync.test.ts b/ui/desktop/src/utils/sessionBindingSync.test.ts index c42b159a0..c3d5eba74 100644 --- a/ui/desktop/src/utils/sessionBindingSync.test.ts +++ b/ui/desktop/src/utils/sessionBindingSync.test.ts @@ -1,5 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { announceSessionBinding, subscribeSessionBindingChanges } from './sessionBindingSync'; +import { + announceAppModelSelection, + announceSessionBinding, + subscribeAppModelSelectionChanges, + subscribeSessionBindingChanges, +} from './sessionBindingSync'; + +const deliver = () => new Promise((resolve) => setTimeout(resolve, 0)); /** * Handoff 04 — a per-chat model switch made in one window has to reach the @@ -89,3 +96,115 @@ describe('sessionBindingSync', () => { expect(seen).toEqual([]); }); }); + +/** + * F3 (provider QA, 2026-09-10). The app-wide selection — the pair `/agent/start` + * binds a new chat to — crosses windows on the same channel as the binding, and + * the difference between the two messages is the whole design: a binding is a + * fact about one row, the selection announcement is a NUDGE to re-read. See + * "The second fact crosses too" in the module header. + */ +describe('sessionBindingSync — the app-wide selection', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('wakes local listeners synchronously, so the writing window re-reads too', () => { + let woken = 0; + const unsubscribe = subscribeAppModelSelectionChanges(() => { + woken += 1; + }); + + announceAppModelSelection(); + + expect(woken).toBe(1); + unsubscribe(); + }); + + /** + * ⚠ A kind and nothing else. A receiver that could read a provider and a + * model off the message would eventually apply them, and two windows' writes + * can be announced in the opposite order from the one they landed in. + */ + it('posts a nudge carrying no provider and no model', () => { + const posted: unknown[] = []; + vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation((message: unknown) => { + posted.push(message); + }); + + const unsubscribe = subscribeAppModelSelectionChanges(() => {}); + announceAppModelSelection(); + unsubscribe(); + + expect(posted).toEqual([{ kind: 'app-model-selection' }]); + }); + + it('wakes on a nudge another window posted', async () => { + let woken = 0; + const unsubscribe = subscribeAppModelSelectionChanges(() => { + woken += 1; + }); + + const other = new BroadcastChannel('biorouter:session-binding'); + other.postMessage({ kind: 'app-model-selection' }); + await deliver(); + other.close(); + unsubscribe(); + + expect(woken).toBe(1); + }); + + /** + * One channel, two facts, told apart by shape — and neither may be mistaken + * for the other. A nudge must not reach a row patcher (it names no session to + * patch), and a binding must not make every window re-read its selection: a + * per-chat switch over there says nothing about new chats over here. + */ + it('keeps the two facts apart on the one channel', async () => { + const bindings: unknown[] = []; + let nudges = 0; + const offBinding = subscribeSessionBindingChanges((change) => bindings.push(change)); + const offSelection = subscribeAppModelSelectionChanges(() => { + nudges += 1; + }); + + const other = new BroadcastChannel('biorouter:session-binding'); + other.postMessage({ kind: 'app-model-selection' }); + other.postMessage({ sessionId: 's5', provider: 'codex', model: 'gpt-6-astra' }); + await deliver(); + other.close(); + offBinding(); + offSelection(); + + expect(nudges).toBe(1); + expect(bindings).toEqual([{ sessionId: 's5', provider: 'codex', model: 'gpt-6-astra' }]); + }); + + it('ignores a message of some other kind', async () => { + let nudges = 0; + const unsubscribe = subscribeAppModelSelectionChanges(() => { + nudges += 1; + }); + + const other = new BroadcastChannel('biorouter:session-binding'); + other.postMessage({ kind: 'something-else' }); + other.postMessage('app-model-selection'); + await deliver(); + other.close(); + unsubscribe(); + + expect(nudges).toBe(0); + }); + + it('stops waking a listener once it unsubscribes', () => { + let woken = 0; + const unsubscribe = subscribeAppModelSelectionChanges(() => { + woken += 1; + }); + unsubscribe(); + + announceAppModelSelection(); + + expect(woken).toBe(0); + }); +}); diff --git a/ui/desktop/src/utils/sessionBindingSync.ts b/ui/desktop/src/utils/sessionBindingSync.ts index 3ac6e1fb3..6fbb3efa1 100644 --- a/ui/desktop/src/utils/sessionBindingSync.ts +++ b/ui/desktop/src/utils/sessionBindingSync.ts @@ -66,13 +66,34 @@ * the selection says what a NEW chat will run on. A window that hears "chat X is * now bound to Y" learns something true about chat X and nothing at all about * its own next new chat — which is exactly right, because a per-chat switch made - * over there is not a statement about new chats over here. The global default - * does move underneath both windows, and each picks it up from its own config - * read; that is unchanged, and unrelated. + * over there is not a statement about new chats over here. * * What must NOT cross is a claim the receiver cannot check. So the receiver * treats an announcement about a chat it holds as authoritative (the daemon * accepted the write before it was announced) and ignores everything else. + * + * # The second fact crosses too — F3 + * + * This header used to end the objection above with "the global default does + * move underneath both windows, and each picks it up from its own config read". + * **Each window read it once, when it mounted, and never again.** Measured on + * 2026-09-10 (provider QA, finding F3): switch the app-wide model in window 1 and + * window 2's chip never moved — it went on reading `gpt-5.5-2026-04-24 (Private + * model, UCSF)` while the chat it started bound `claude_code`, because + * `/agent/start` binds whatever `config.yaml` says at that instant. The session + * was classified `public` correctly; the label the user acted on was the lie. + * + * So the app-wide selection has its own announcement on this same channel + * ({@link announceAppModelSelection}). It differs from the binding in one way + * that matters: it is a **nudge, never a payload**. A binding is a fact about + * one row that the daemon accepted before it was announced; the selection is + * one pair of config keys that any window, the CLI or a hand edit may be + * rewriting at the same moment, and two announcements can arrive in the + * opposite order from the two writes they describe. A receiver that applied the + * values would end on whichever message arrived last; one that re-reads the + * daemon ends on whichever WRITE landed last, which is what `/agent/start` will + * bind. The same reason `catalogSubscription` refetches rather than applying a + * delta. */ export interface SessionBindingChange { @@ -97,10 +118,25 @@ type Listener = (change: SessionBindingChange) => void; const listeners = new Set(); +/** + * The wire form of {@link announceAppModelSelection}: a kind and nothing else. + * + * ⚠ No provider, no model — deliberately, and not for brevity. See "The second + * fact crosses too" above: a receiver that could read the values off the message + * would eventually be written to apply them, and applying them is the race. + */ +export const APP_MODEL_SELECTION_MESSAGE = { kind: 'app-model-selection' } as const; + +type AppModelSelectionListener = () => void; + +const appSelectionListeners = new Set(); + // ── Cross-window broadcast ──────────────────────────────────────────────── // Same mechanism and same channel shape as `sessionNameSync`: BroadcastChannel // reaches every React subtree in this renderer AND every other BrowserWindow of -// the same origin, which is what a second Biorouter window is. +// the same origin, which is what a second Biorouter window is. ONE channel +// carries both facts, told apart by shape: a binding names a session, the +// selection nudge names a `kind` and no session. let channel: BroadcastChannel | null = null; function getChannel(): BroadcastChannel | null { @@ -108,7 +144,16 @@ function getChannel(): BroadcastChannel | null { if (typeof BroadcastChannel === 'undefined') return null; channel = new BroadcastChannel('biorouter:session-binding'); channel.onmessage = (event: MessageEvent) => { - const change = event.data as SessionBindingChange | undefined; + const data = event.data as + | SessionBindingChange + | typeof APP_MODEL_SELECTION_MESSAGE + | null + | undefined; + if (data && (data as { kind?: unknown }).kind === APP_MODEL_SELECTION_MESSAGE.kind) { + for (const listener of [...appSelectionListeners]) listener(); + return; + } + const change = data as SessionBindingChange | null | undefined; // Shape-checked, not trusted: this arrives from another window and a // malformed message must not patch a row with `undefined`. if (!change || !change.sessionId || !change.provider || !change.model) return; @@ -141,3 +186,39 @@ export function announceSessionBinding(change: SessionBindingChange): void { for (const listener of [...listeners]) listener(change); getChannel()?.postMessage(change); } + +/** + * Subscribe to "the app-wide model selection may have moved". Returns the + * unsubscribe. + * + * The listener gets no values, only the nudge: re-read `BIOROUTER_PROVIDER` / + * `BIOROUTER_MODEL` from the daemon and state what comes back. Subscribe from a + * MOUNT (`ModelAndProviderProvider`'s effect), never from a lookup — a + * subscription is a side effect, and one hung off a getter runs in every test + * that calls the getter. + */ +export function subscribeAppModelSelectionChanges(listener: () => void): () => void { + getChannel(); + appSelectionListeners.add(listener); + return () => { + appSelectionListeners.delete(listener); + }; +} + +/** + * Announce that `BIOROUTER_PROVIDER` / `BIOROUTER_MODEL` were just written. + * + * Call it AFTER the write resolved: a receiver re-reads the daemon, and a nudge + * that outran its write would re-read the value it was sent to replace — and + * then, with nothing further to wake it, keep stating it. + * + * Local listeners run synchronously, as {@link announceSessionBinding}'s do, so + * the window that wrote re-reads too. That is not redundant: the writer is not + * always `ModelAndProviderContext` itself (onboarding and Lead/Worker write + * these keys through `ConfigContext.upsert`), and a read issued after the write + * is what settles a race with another window's write. + */ +export function announceAppModelSelection(): void { + for (const listener of [...appSelectionListeners]) listener(); + getChannel()?.postMessage({ ...APP_MODEL_SELECTION_MESSAGE }); +} From 750d3d788d7dc38ce1fecbc4e6df0c5c1d47afe8 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 11:48:09 -0700 Subject: [PATCH 2/5] desktop: every window states the model its next new chat will run on (F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 (provider QA, 2026-09-10, HIGH). Change the app-wide model in window 1 and window 2's chip never moved. Window 2 read `gpt-5.5-2026-04-24 (Private model, UCSF)` at the instant of send; the chat it created bound `claude_code`, was classified public — correctly — and its turn went to a consumer subscription with no BAA. `ModelAndProviderContext` read BIOROUTER_PROVIDER/BIOROUTER_MODEL once, on mount, while `/agent/start` binds whatever those keys say on the daemon at that instant. - ModelAndProviderContext re-reads the pair (a pure read, never the fallback seeding) on the app-wide announcement and when its window regains focus or becomes visible. Every statement of the selection — mount read, re-read, own switch — is ticketed and publishes only if nothing issued after it has been published, compared against what was last APPLIED (a failed newer read must not condemn an older good one). A read that returns no body keeps the label rather than erasing it. - Every renderer write of the two keys announces: `changeModel`, the first-run default seeding, and ConfigContext's `upsert`/`remove` — which covers onboarding's local and coding-agent cards, Lead/Worker and reset, none of which updated even their own window's chip before. - A switch made from inside a chat now changes THAT chat only, unless the new "Also use for new chats" box is ticked (unticked by default). This is privacy-tiers §14.3 P4's recommended decoupling: QA F bound Claude Code in one chat for one check and the next chat it opened came up public. With no chat (Home, a chat not yet started, Settings, onboarding) a switch sets the model new chats start on, and the dialog, the success toast and the chip's dropdown ("Model for new chats") now say so, including "in every window". - Both new-chat composers (Home and a not-yet-started chat) re-read the pair immediately before `createSession`. If the chip was stale — a `biorouter configure` in the terminal docked inside the window never takes its focus — the send is refused, the fresh model and its tier go on screen, a toast names what changed, and the composer gets the text back. The pin still outranks a stale row: the app-wide selection touches neither. --- ui/desktop/src/components/BaseChat.tsx | 17 +- .../src/components/ConfigContext.test.tsx | 97 ++- ui/desktop/src/components/ConfigContext.tsx | 19 + ui/desktop/src/components/Hub.tsx | 9 +- ...delAndProviderContext.crossWindow.test.tsx | 619 ++++++++++++++++++ .../ModelAndProviderContext.test.tsx | 47 +- .../components/ModelAndProviderContext.tsx | 256 +++++++- .../privacy/useConfirmNewChatModel.test.tsx | 192 ++++++ .../privacy/useConfirmNewChatModel.ts | 101 +++ .../ModelsBottomBar.pinned.test.tsx | 41 +- .../models/bottom_bar/ModelsBottomBar.tsx | 25 +- .../SwitchModelModal.privacy.test.tsx | 15 +- .../subcomponents/SwitchModelModal.test.tsx | 125 +++- .../models/subcomponents/SwitchModelModal.tsx | 56 +- 14 files changed, 1565 insertions(+), 54 deletions(-) create mode 100644 ui/desktop/src/components/ModelAndProviderContext.crossWindow.test.tsx create mode 100644 ui/desktop/src/components/privacy/useConfirmNewChatModel.test.tsx create mode 100644 ui/desktop/src/components/privacy/useConfirmNewChatModel.ts diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index d3abed351..0b2711a11 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -43,6 +43,7 @@ import { WorkflowWarningModal } from './ui/WorkflowWarningModal'; import { NonPrivateModelDisclosureGate } from './privacy/NonPrivateModelDisclosureGate'; import { PinnedModelNote } from './privacy/PinnedModelNote'; import { usePinnedModel } from './privacy/usePinnedModel'; +import { useConfirmNewChatModel } from './privacy/useConfirmNewChatModel'; import { scanWorkflow } from '../workflow'; import { useCostTracking } from '../hooks/useCostTracking'; import { useDiverge } from '../hooks/useDiverge'; @@ -1203,6 +1204,8 @@ function BaseChatContent({ const [hasNotAcceptedWorkflow, setHasNotAcceptedWorkflow] = useState(); const [hasWorkflowSecurityWarnings, setHasWorkflowSecurityWarnings] = useState(false); const [isCreatingSession, setIsCreatingSession] = useState(false); + // F3 — the model this chat is about to be created on is the one on screen. + const confirmNewChatModel = useConfirmNewChatModel(); // #39 — the working directory chosen in the composer BEFORE a session // exists (sidebar "New chat" mounts this chat with no sessionId, so // DirSwitcher has nothing to persist to yet). Read exactly once, by the @@ -1575,10 +1578,12 @@ function BaseChatContent({ /** * Resolves FALSE when the message was refused and the composer still owns the * text (ChatInput puts it back). The pre-session branch returns TRUE on both - * of its outcomes: a created session has navigated with the message as its - * cargo, and a failed `createSession` has already restored the composer and - * toasted through `handleCreateSessionError`, so a second restore would be a - * duplicate rather than a rescue. + * of its outcomes once a session is attempted: a created session has + * navigated with the message as its cargo, and a failed `createSession` has + * already restored the composer and toasted through `handleCreateSessionError`, + * so a second restore would be a duplicate rather than a rescue. It returns + * FALSE only when F3's model check refused BEFORE anything was attempted — + * the one case where the composer's own restore is the rescue. */ const handleFormSubmit = async (e: React.FormEvent): Promise => { const customEvent = e as unknown as CustomEvent; @@ -1591,6 +1596,10 @@ function BaseChatContent({ // If no session exists, create one and navigate with the initial message const hasAttachments = Array.isArray(attachments) && attachments.length > 0; if (!session && !sessionId && (textValue.trim() || hasAttachments) && !isCreatingSession) { + // F3. `/agent/start` binds whatever the app-wide selection is NOW, and the + // composer's chip is this window's copy of it. A refusal here has already + // put the fresh model on screen; resolving `false` hands the text back. + if (!(await confirmNewChatModel())) return false; setIsCreatingSession(true); try { // #39 — honour the directory picked in the composer before the diff --git a/ui/desktop/src/components/ConfigContext.test.tsx b/ui/desktop/src/components/ConfigContext.test.tsx index 03d38176a..3bc40ab03 100644 --- a/ui/desktop/src/components/ConfigContext.test.tsx +++ b/ui/desktop/src/components/ConfigContext.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ConfigProvider, useConfig } from './ConfigContext'; // Issue #52 — the cached `config` object was only ever re-read when a write @@ -412,3 +412,98 @@ describe('ConfigContext catalogue subscription (#112)', () => { expect(unhandled).toEqual([]); }); }); + +/** + * F3 (provider QA, 2026-09-10). `BIOROUTER_PROVIDER` and `BIOROUTER_MODEL` are + * what `/agent/start` binds a new chat to, so a write of either through this + * context is announced to every window — each of which re-reads the pair. The + * writers this catches are the ones that never pass through + * `ModelAndProviderContext.changeModel`: onboarding's local and coding-agent + * cards, Lead/Worker settings and Settings' reset, which until now left even + * their own window's chip naming the previous model. + */ +describe('ConfigContext announces writes of the app-wide model selection (F3)', () => { + function WriteProbe() { + const { upsert, remove } = useConfig(); + const [result, setResult] = useState('idle'); + const run = (write: () => Promise) => { + setResult('pending'); + write().then( + () => setResult('ok'), + (error: unknown) => setResult(`failed: ${String(error)}`) + ); + }; + return ( +
+ {result} + + + + +
+ ); + } + + let nudges = 0; + let unsubscribe: () => void = () => {}; + + beforeEach(async () => { + nudges = 0; + const { subscribeAppModelSelectionChanges } = await import('../utils/sessionBindingSync'); + unsubscribe = subscribeAppModelSelectionChanges(() => { + nudges += 1; + }); + mocks.removeConfig.mockResolvedValue({ data: {} }); + }); + + afterEach(() => unsubscribe()); + + const renderWriteProbe = () => + render( + + + + ); + + it.each([['Write provider'], ['Write model'], ['Remove model']])( + '%s announces, once the write has resolved', + async (button) => { + renderWriteProbe(); + fireEvent.click(screen.getByRole('button', { name: button })); + await waitFor(() => expect(screen.getByTestId('write-result')).toHaveTextContent('ok')); + expect(nudges).toBe(1); + } + ); + + it('says nothing about a key a new chat does not bind', async () => { + renderWriteProbe(); + fireEvent.click(screen.getByRole('button', { name: 'Write mode' })); + await waitFor(() => expect(screen.getByTestId('write-result')).toHaveTextContent('ok')); + expect(nudges).toBe(0); + }); + + /** A refused write moved nothing, so there is nothing to re-read. */ + it('says nothing when the write was refused', async () => { + mocks.upsertConfig.mockRejectedValue(new Error('409 Conflict')); + renderWriteProbe(); + fireEvent.click(screen.getByRole('button', { name: 'Write provider' })); + await waitFor(() => + expect(screen.getByTestId('write-result')).toHaveTextContent('failed: Error: 409 Conflict') + ); + expect(nudges).toBe(0); + }); +}); diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 68de4ae61..5fcd1f9b5 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -30,6 +30,7 @@ import { shouldDefaultEnablePromotedCapability, } from './settings/capabilities/capabilities'; import { PRIVACY_TIERS_KEY, privacyTiersEnabledFromConfig } from './settings/privacy/privacyTiers'; +import { announceAppModelSelection } from '../utils/sessionBindingSync'; import type { ConfigResponse, UpsertConfigQuery, @@ -95,6 +96,21 @@ export class MalformedConfigError extends Error { const ConfigContext = createContext(undefined); +/** + * F3 — the two keys `/agent/start` binds a new chat to. + * + * A write of either changes what every window's composer must state, so it is + * announced to all of them (`utils/sessionBindingSync`), each of which re-reads + * the pair. `ModelAndProviderContext.changeModel` announces its own writes; the + * ones caught HERE are those that never pass through it — the local and + * coding-agent onboarding cards, Lead/Worker settings, Settings' reset — which + * until now left even their own window's chip naming the previous model. + */ +const APP_MODEL_SELECTION_KEYS: ReadonlySet = new Set([ + 'BIOROUTER_PROVIDER', + 'BIOROUTER_MODEL', +]); + export const ConfigProvider: React.FC = ({ children }) => { const [config, setConfig] = useState({}); const [providersList, setProvidersList] = useState([]); @@ -201,6 +217,8 @@ export const ConfigProvider: React.FC = ({ children }) => { headers: await userActionHeaders(), }); await reloadConfigAfterWrite(); + // After the write resolved — a refused one threw above and moved nothing. + if (APP_MODEL_SELECTION_KEYS.has(key)) announceAppModelSelection(); }, [reloadConfigAfterWrite] ); @@ -226,6 +244,7 @@ export const ConfigProvider: React.FC = ({ children }) => { headers: await userActionHeaders(), }); await reloadConfigAfterWrite(); + if (APP_MODEL_SELECTION_KEYS.has(key)) announceAppModelSelection(); }, [reloadConfigAfterWrite] ); diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 8b4228478..3e15b77ed 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -29,6 +29,7 @@ import { getInitialWorkingDir } from '../utils/workingDir'; import { createSession } from '../sessions'; import LoadingBioRouter from './LoadingBioRouter'; import type { UserAttachment } from '../types/message'; +import { useConfirmNewChatModel } from './privacy/useConfirmNewChatModel'; export default function Hub({ setView, @@ -38,14 +39,20 @@ export default function Hub({ const { extensionsList } = useConfig(); const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); const [isCreatingSession, setIsCreatingSession] = useState(false); + const confirmNewChatModel = useConfirmNewChatModel(); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent): Promise => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; const attachments = (customEvent.detail?.attachments ?? []) as UserAttachment[]; const hasAttachments = attachments.length > 0; if ((combinedTextFromInput.trim() || hasAttachments) && !isCreatingSession) { + // F3. Before anything is consumed — the extension overrides below are + // cleared as they are read — so a refused send leaves nothing behind but + // the text, which `ChatInput` puts back when this resolves `false`. + if (!(await confirmNewChatModel())) return false; + const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); clearExtensionOverrides(); setIsCreatingSession(true); diff --git a/ui/desktop/src/components/ModelAndProviderContext.crossWindow.test.tsx b/ui/desktop/src/components/ModelAndProviderContext.crossWindow.test.tsx new file mode 100644 index 000000000..0497a8e7d --- /dev/null +++ b/ui/desktop/src/components/ModelAndProviderContext.crossWindow.test.tsx @@ -0,0 +1,619 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useState } from 'react'; +import { + ModelAndProviderProvider, + useModelAndProvider, + type ChangeModelOptions, +} from './ModelAndProviderContext'; +import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; +import { __resetDisclosureStoreForTests } from './privacy/disclosureCopy'; +import { usePinnedModel } from './privacy/usePinnedModel'; +import { useConfirmNewChatModel } from './privacy/useConfirmNewChatModel'; +import type Model from './settings/models/modelInterface'; +import type { Session } from '../api/types.gen'; +import type { PinnedModelView } from '../hooks/chatStreamStore'; + +/** + * F3 (provider QA, 2026-09-10) — a second window's model chip was stale, and the + * chat it started ran on a model it never showed. + * + * Measured on merged main `7c96d796`, both directions: change the app-wide + * model in window 1 and window 2's chip never moved (8 s). Window 2 read + * `gpt-5.5-2026-04-24 (Private model, UCSF)` at the instant of send; the chat it + * created bound `claude_code`, was classified `public` — correctly — and its + * turn went to a consumer subscription with no BAA. The privacy machinery held; + * the label the human acted on did not. + * + * Root cause: `ModelAndProviderContext` read `BIOROUTER_PROVIDER` / + * `BIOROUTER_MODEL` once, on mount, while `/agent/start` binds a new chat to + * whatever those keys say on the daemon at that instant. + * + * Every test here mounts TWO `ModelAndProviderProvider` trees — two windows' + * worth of state in one document — each rendering the real composer chip, over + * one fake daemon whose two keys are what `/agent/start` would bind. The + * assertion that matters throughout is the one the QA run could not make: the + * chip a window shows equals what its next new chat would run on. + */ + +const mocks = vi.hoisted(() => ({ + read: vi.fn(), + getProviders: vi.fn(), + refreshConfig: vi.fn(), + setConfigProvider: vi.fn(), + updateAgentProvider: vi.fn(), + llamacppStatus: vi.fn(), + llamacppWarmup: vi.fn(), + getPrivacyDisclosure: vi.fn(), + ackPrivacyDisclosure: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), + toastWarning: vi.fn(), +})); + +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal>()), + setConfigProvider: mocks.setConfigProvider, + updateAgentProvider: mocks.updateAgentProvider, + llamacppStatus: mocks.llamacppStatus, + llamacppWarmup: mocks.llamacppWarmup, + getPrivacyDisclosure: mocks.getPrivacyDisclosure, + ackPrivacyDisclosure: mocks.ackPrivacyDisclosure, +})); + +vi.mock('../toasts', async (importOriginal) => ({ + ...(await importOriginal>()), + toastSuccess: mocks.toastSuccess, + toastError: mocks.toastError, + toastWarning: mocks.toastWarning, +})); + +vi.mock('../utils/userAction', async (importOriginal) => ({ + ...(await importOriginal>()), + userActionHeaders: async () => ({ 'X-User-Action': 'test-key' }), +})); + +// `usePrivacyTiersEnabled` too: the chip's padlock reads the master switch. +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ + read: mocks.read, + getProviders: mocks.getProviders, + refreshConfig: mocks.refreshConfig, + }), + usePrivacyTiersEnabled: () => true, +})); + +// `BaseChat` is the whole chat surface; the chip imports it for one dead context. +vi.mock('./BaseChat', () => ({ useCurrentModelInfo: () => null })); +vi.mock('./settings/models/subcomponents/SwitchModelModal', () => ({ + SwitchModelModal: () => null, +})); +vi.mock('./settings/models/subcomponents/LeadWorkerSettings', () => ({ + LeadWorkerSettings: () => null, +})); + +Object.defineProperty(window, 'appConfig', { + writable: true, + value: { get: () => undefined }, +}); + +// ── The daemon ───────────────────────────────────────────────────────────── + +const PRIVATE = { provider: 'versa_azure', model: 'gpt-5.5-2026-04-24' }; +const PUBLIC = { provider: 'claude_code', model: 'claude-fable-5-1' }; +const CODEX = { provider: 'codex', model: 'gpt-6-astra' }; + +/** `config.yaml`'s two keys, as the daemon holds them. */ +const daemon: { provider: string | null; model: string | null } = { ...PRIVATE }; + +/** + * What the next `/agent/start` binds: `configured_new_session_provider` reads + * exactly these two keys and nothing the renderer sends. + */ +const nextNewChatBinding = () => ({ provider: daemon.provider, model: daemon.model }); + +/** A write made by anything other than the renderer: the CLI, a hand edit. */ +const writeOutsideTheRenderer = (next: { provider: string; model: string }) => { + daemon.provider = next.provider; + daemon.model = next.model; +}; + +const UCSF = { kind: 'institutions', institutions: [{ id: 'ucsf', display_name: 'UCSF' }] }; + +const providerRow = ( + name: string, + display: string, + tier: 'private' | 'public', + affiliation: unknown = null +) => ({ + name, + is_configured: true, + provider_type: 'Builtin', + metadata: { name, display_name: display, tier, runs_locally: false, known_models: [] }, + affiliation, + resolved_tier: tier, +}); + +const PROVIDER_ROWS = [ + providerRow('versa_azure', 'Versa API Azure', 'private', UCSF), + providerRow('claude_code', 'Claude Code', 'public'), + providerRow('codex', 'Codex', 'public'), +]; + +/** The chip's accessible name, exactly as a screen reader — or QA — reads it. */ +const CHIP = { + [PRIVATE.model]: `Current model: ${PRIVATE.model} (Private model, UCSF)`, + [PUBLIC.model]: `Current model: ${PUBLIC.model} (Public model)`, + [CODEX.model]: `Current model: ${CODEX.model} (Public model)`, +}; + +/** The chip's label for whatever pair the daemon holds right now. */ +const chipForDaemon = () => CHIP[nextNewChatBinding().model as string]; + +/** A second window speaking on the channel, as `BroadcastChannel` delivers it. */ +async function announceFromAnotherWindow() { + const other = new BroadcastChannel('biorouter:session-binding'); + other.postMessage({ kind: 'app-model-selection' }); + // Delivery is a task, not a microtask; close only once it has happened. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + other.close(); +} + +// ── The windows ──────────────────────────────────────────────────────────── + +const dropdownRef = { current: null } as unknown as React.RefObject; + +const asModel = (pair: { provider: string; model: string }): Model => ({ + name: pair.model, + provider: pair.provider, + subtext: pair.provider, +}); + +/** + * One window's Home composer: its chip, plus the two acts that matter — the + * model switcher's commit and a send that would create a new chat. + */ +function HomeWindow({ label }: { label: string }) { + const { changeModel } = useModelAndProvider(); + const confirmNewChatModel = useConfirmNewChatModel(); + const [sendResult, setSendResult] = useState('idle'); + return ( +
+ + + + + {sendResult} +
+ ); +} + +/** One window showing an existing chat, with the switcher committing for it. */ +function ChatWindow({ + label, + session, + pin, + switchOptions, +}: { + label: string; + session: Session; + pin?: PinnedModelView; + switchOptions?: ChangeModelOptions; +}) { + const { changeModel } = useModelAndProvider(); + const { effectiveModel } = usePinnedModel(session, pin); + return ( +
+ + +
+ ); +} + +const inWindow = (label: string) => within(screen.getByRole('region', { name: label })); + +const chipIn = (label: string) => + inWindow(label).getByRole('button', { name: /^Current model:/ }) as HTMLElement; + +async function expectChip(label: string, name: string) { + await waitFor(() => expect(chipIn(label)).toHaveAccessibleName(name)); +} + +function renderTwoHomeWindows() { + return render( + <> + + + + + + + + ); +} + +const session = (overrides: Partial): Session => + ({ + id: 'chat-a', + working_dir: '/tmp', + name: 'A chat', + message_count: 3, + privacy_tier: 'private', + ...overrides, + }) as Session; + +beforeEach(() => { + vi.clearAllMocks(); + __resetDisclosureStoreForTests(); + Object.assign(daemon, PRIVATE); + mocks.read.mockImplementation(async (key: string) => { + if (key === 'BIOROUTER_MODEL') return daemon.model; + if (key === 'BIOROUTER_PROVIDER') return daemon.provider; + return null; + }); + mocks.getProviders.mockResolvedValue(PROVIDER_ROWS); + mocks.refreshConfig.mockResolvedValue(undefined); + // `/config/set_provider`, as the daemon applies it. + mocks.setConfigProvider.mockImplementation( + async ({ body }: { body: { provider: string; model: string } }) => { + daemon.provider = body.provider; + daemon.model = body.model; + return { data: null }; + } + ); + mocks.updateAgentProvider.mockResolvedValue({ data: '' }); + mocks.getPrivacyDisclosure.mockResolvedValue({ + data: { + title_template: '{provider} is not hosted by your institution.', + long: 'LONG', + short: 'SHORT', + acknowledged: true, + }, + }); +}); + +describe('F3 — the app-wide selection reaches every window', () => { + /** + * The brief's first test, as stated: two consumers, the broadcast, and the + * second one's model, provider and privacy label updating without a remount. + * The announcement arrives the way another BrowserWindow's does — on the + * channel, from a different `BroadcastChannel` object. + * + * Fails on 7c96d796: nothing listens for it, and window 2 keeps + * `gpt-5.5-2026-04-24 (Private model, UCSF)` for as long as it lives. + */ + it("updates a second window's chip — model, provider and privacy — without a remount", async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + const before = chipIn('window 2'); + + writeOutsideTheRenderer(PUBLIC); + await announceFromAnotherWindow(); + + await expectChip('window 2', CHIP[PUBLIC.model]); + expect(chipIn('window 2')).not.toHaveAccessibleName(/Private model/); + // The same element, re-rendered — not a fresh mount that re-read on mount. + expect(chipIn('window 2')).toBe(before); + }); + + /** + * The brief's second test: a stale label cannot survive a change, in either + * direction. "The value the next `/agent/start` would bind" is the fake + * daemon's pair, which is exactly what `configured_new_session_provider` + * reads. + */ + it('states what the next new chat would bind, after a switch in either direction', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + // Private → public. The direction QA measured going to a no-BAA plan. + fireEvent.click(inWindow('window 1').getByRole('button', { name: /to Claude Code/ })); + await waitFor(() => expect(nextNewChatBinding()).toEqual(PUBLIC)); + await expectChip('window 2', chipForDaemon()); + expect(chipIn('window 2')).toHaveAccessibleName(CHIP[PUBLIC.model]); + expect(chipIn('window 1')).toHaveAccessibleName(CHIP[PUBLIC.model]); + + // And back. + fireEvent.click(inWindow('window 1').getByRole('button', { name: /to Versa/ })); + await waitFor(() => expect(nextNewChatBinding()).toEqual(PRIVATE)); + await expectChip('window 2', chipForDaemon()); + expect(chipIn('window 2')).toHaveAccessibleName(CHIP[PRIVATE.model]); + }); + + /** + * ⚠ The nudge is not a payload, and this is why: two windows' writes can be + * announced in the opposite order from the one in which they landed. A + * receiver that applied values would end on the last MESSAGE; one that + * re-reads ends on the last WRITE — the one `/agent/start` binds. + */ + it('ends on the write that landed last, not the announcement that arrived last', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + // Two writes land — Codex, then Claude Code — and both are announced. + writeOutsideTheRenderer(CODEX); + writeOutsideTheRenderer(PUBLIC); + await announceFromAnotherWindow(); + await announceFromAnotherWindow(); + + await expectChip('window 2', CHIP[PUBLIC.model]); + }); +}); + +describe('F3 — reads are ordered by when they were issued', () => { + /** + * A read that left before a newer statement was published must not come back + * afterwards and restore the model the newer one replaced. Window 2 re-reads + * twice; the first answer is held until after the second has landed. + */ + it('does not let a slower, older read overwrite a newer one', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + let releaseOld: (value: string) => void = () => {}; + const heldModel = new Promise((resolve) => { + releaseOld = resolve; + }); + // While `holding`, every read answers with the OLD pair, and slowly. + let holding = true; + mocks.read.mockImplementation(async (key: string) => { + if (holding && key === 'BIOROUTER_MODEL') return heldModel; + if (holding && key === 'BIOROUTER_PROVIDER') return PRIVATE.provider; + if (key === 'BIOROUTER_MODEL') return daemon.model; + if (key === 'BIOROUTER_PROVIDER') return daemon.provider; + return null; + }); + + await announceFromAnotherWindow(); + holding = false; + writeOutsideTheRenderer(PUBLIC); + await announceFromAnotherWindow(); + await expectChip('window 2', CHIP[PUBLIC.model]); + + // The first read finally answers — with the pair the second one replaced. + await act(async () => { + releaseOld(PRIVATE.model); + await heldModel; + }); + expect(chipIn('window 2')).toHaveAccessibleName(CHIP[PUBLIC.model]); + }); + + /** + * ⚠ The other half of the rule: a NEWER read that fails publishes nothing, so + * it must not condemn an older one that succeeded + * (`renderer-testing-traps.md`, "Newest issued is the wrong rule"). + */ + it('keeps an older read that succeeded when a newer one fails', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + writeOutsideTheRenderer(PUBLIC); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + // The first nudge's reads are slow and right; the second's are fast and + // failed — the generated client resolves a dead daemon with no body. + let phase: 'slow' | 'failing' = 'slow'; + mocks.read.mockImplementation(async (key: string) => { + if (phase === 'failing') return undefined; + await gate; + if (key === 'BIOROUTER_MODEL') return daemon.model; + if (key === 'BIOROUTER_PROVIDER') return daemon.provider; + return null; + }); + + await announceFromAnotherWindow(); + phase = 'failing'; + await announceFromAnotherWindow(); + await act(async () => { + release(); + await gate; + }); + + await expectChip('window 2', CHIP[PUBLIC.model]); + }); + + /** A failed read is not evidence that nothing is configured. */ + it('keeps the label it has when a re-read fails, rather than erasing it', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + mocks.read.mockResolvedValue(undefined); + await announceFromAnotherWindow(); + + expect(chipIn('window 2')).toHaveAccessibleName(CHIP[PRIVATE.model]); + expect(screen.queryByRole('button', { name: 'Choose a model' })).toBeNull(); + }); +}); + +describe('F3 — a write nothing announces', () => { + /** + * `biorouter configure` in a terminal writes `config.yaml`, the daemon's + * cache is keyed on the file's stamp, and nothing tells any window. Coming + * back to the window is when the user acts, so that is when it re-reads. + */ + it('re-reads when the window regains focus', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + writeOutsideTheRenderer(PUBLIC); + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + + await expectChip('window 2', CHIP[PUBLIC.model]); + }); + + it('re-reads when the window becomes visible again', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + writeOutsideTheRenderer(CODEX); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + await expectChip('window 2', CHIP[CODEX.model]); + }); + + /** + * A terminal docked INSIDE the window never takes its focus, so a send can + * still be the first thing to notice. It is refused, the fresh model is put + * on screen, and the toast says what changed in words — including the tier, + * which is the reason any of this matters. + */ + it('refuses a send made on a stale chip, and shows the model it would have used', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + writeOutsideTheRenderer(PUBLIC); + fireEvent.click(inWindow('window 2').getByRole('button', { name: 'Send' })); + + await waitFor(() => expect(screen.getByTestId('window 2-send')).toHaveTextContent('false')); + await expectChip('window 2', CHIP[PUBLIC.model]); + expect(mocks.toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Message not sent', + msg: expect.stringContaining( + `New chats now start on ${PUBLIC.model} (Claude Code, a public model), not ${PRIVATE.model}` + ), + }) + ); + }); + + it('lets a send through when the chip already states what a new chat binds', async () => { + renderTwoHomeWindows(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + fireEvent.click(inWindow('window 2').getByRole('button', { name: 'Send' })); + + await waitFor(() => expect(screen.getByTestId('window 2-send')).toHaveTextContent('true')); + expect(mocks.toastWarning).not.toHaveBeenCalled(); + }); +}); + +describe('F3 / privacy-tiers P4 — a switch made in a chat is about that chat', () => { + function renderChatAndHome(switchOptions?: ChangeModelOptions) { + return render( + <> + + + + + + + + ); + } + + /** + * QA F bound Claude Code in one chat for one check, and the next chat it + * opened came up public: the switch had silently moved the app-wide default. + * Unticked, it moves the chat and nothing else — so the other window's + * new-chat chip does not move, and it is RIGHT not to. + */ + it('leaves the model new chats start on alone, in every window', async () => { + renderChatAndHome(); + await expectChip('window 2', CHIP[PRIVATE.model]); + + fireEvent.click(inWindow('window 1').getByRole('button', { name: /this chat to Codex/ })); + + await waitFor(() => expect(mocks.updateAgentProvider).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalled()); + expect(mocks.setConfigProvider).not.toHaveBeenCalled(); + expect(nextNewChatBinding()).toEqual(PRIVATE); + expect(chipIn('window 2')).toHaveAccessibleName(chipForDaemon()); + expect(mocks.toastSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + msg: `This chat now uses ${CODEX.model} from ${CODEX.provider}. Other chats, and new ones, are unchanged.`, + }) + ); + }); + + it('moves every window when the user asks for new chats too', async () => { + renderChatAndHome({ alsoForNewChats: true }); + await expectChip('window 2', CHIP[PRIVATE.model]); + + fireEvent.click(inWindow('window 1').getByRole('button', { name: /this chat to Codex/ })); + + await waitFor(() => expect(nextNewChatBinding()).toEqual(CODEX)); + await expectChip('window 2', chipForDaemon()); + expect(chipIn('window 2')).toHaveAccessibleName(CHIP[CODEX.model]); + }); +}); + +describe('F3 — the pin still outranks a stale row', () => { + /** + * A chat's composer states the chat's own binding, and `chatBinding` prefers + * the turn-reported pin over the cached row (`privacy/pinnedModel.ts`, and the + * PIN note in `utils/sessionBindingSync.ts`). + * An app-wide change crossing windows must not disturb that: the chat below + * last RAN on Versa (the pin), its cached row still names Codex, and the + * selection moves to Claude Code. Its chip names Versa before and after. + */ + it('keeps naming the pinned model when the app-wide selection moves under it', async () => { + const stale = session({ + id: 'chat-b', + privacy_tier: 'private', + provider_name: CODEX.provider, + model_config: { model_name: CODEX.model } as Session['model_config'], + }); + render( + <> + + + + + + + + ); + await expectChip( + 'window 2', + `${CHIP[PRIVATE.model]}. Private chat. Biorouter only lets a private model open it.` + ); + + fireEvent.click(inWindow('window 1').getByRole('button', { name: /to Claude Code/ })); + await waitFor(() => expect(nextNewChatBinding()).toEqual(PUBLIC)); + await expectChip('window 1', CHIP[PUBLIC.model]); + + expect(chipIn('window 2')).toHaveAccessibleName( + `${CHIP[PRIVATE.model]}. Private chat. Biorouter only lets a private model open it.` + ); + expect(chipIn('window 2')).not.toHaveAccessibleName(new RegExp(CODEX.model)); + expect(chipIn('window 2')).not.toHaveAccessibleName(new RegExp(PUBLIC.model)); + }); +}); diff --git a/ui/desktop/src/components/ModelAndProviderContext.test.tsx b/ui/desktop/src/components/ModelAndProviderContext.test.tsx index 84eed2516..75409ee43 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.test.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.test.tsx @@ -4,7 +4,12 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ModelAndProviderProvider, useModelAndProvider } from './ModelAndProviderContext'; +import { + ModelAndProviderProvider, + switchedModelMessage, + useModelAndProvider, + type ChangeModelOptions, +} from './ModelAndProviderContext'; import { subscribeSessionBindingChanges } from '../utils/sessionBindingSync'; import type Model from './settings/models/modelInterface'; @@ -167,14 +172,16 @@ const clientRejecting = (body: unknown) => async (options?: { throwOnError?: boo * asserted on the boolean the callers branch on rather than on a rendered toast * alone. */ -function SessionSwitchHarness() { +function SessionSwitchHarness({ options }: { options?: ChangeModelOptions } = {}) { const { changeModel } = useModelAndProvider(); const [result, setResult] = useState('pending'); return ( <> @@ -482,12 +489,12 @@ describe('ModelAndProviderProvider announces the binding it just wrote', () => { }); /** - * ⚠ **Ordering, not just occurrence.** The announcement lands BEFORE - * `setConfigProvider` moves the global default, so in the only render where - * the row and the selection can disagree it is the ROW that holds the new - * binding. Announcing afterwards would invert that window and flash the model - * the user had just switched away from — the regression PR #192 narrowed its - * rule to avoid. + * ⚠ **Ordering, not just occurrence.** When the switch moves the new-chat + * default too, the announcement lands BEFORE `setConfigProvider` moves it, so + * in the only render where the row and the selection can disagree it is the + * ROW that holds the new binding. Announcing afterwards would invert that + * window and flash the model the user had just switched away from — the + * regression PR #192 narrowed its rule to avoid. */ it('before the global default moves, not after', async () => { const order: string[] = []; @@ -503,7 +510,7 @@ describe('ModelAndProviderProvider announces the binding it just wrote', () => { render( - + ); @@ -689,3 +696,23 @@ describe('ModelAndProviderProvider config readiness', () => { await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready:none')); }); }); + +/** + * F3 / privacy-tiers P4 — the success toast names where a switch landed. It + * used to say "Switched models — using X from Y" whichever of the chat and the + * new-chat default had moved, which is how a switch in one chat could quietly + * become what every new chat started on. + */ +describe('switchedModelMessage', () => { + it('names each of the three places a switch can land', () => { + expect(switchedModelMessage('Codex 6', 'Codex', { chat: true, newChats: false })).toBe( + 'This chat now uses Codex 6 from Codex. Other chats, and new ones, are unchanged.' + ); + expect(switchedModelMessage('Codex 6', 'Codex', { chat: true, newChats: true })).toBe( + 'This chat, and new chats in every window, now use Codex 6 from Codex.' + ); + expect(switchedModelMessage('Codex 6', 'Codex', { chat: false, newChats: true })).toBe( + 'New chats in every window now start on Codex 6 from Codex. Existing chats keep their own model.' + ); + }); +}); diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index 1b71ab9b2..775e19a2b 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -1,4 +1,12 @@ -import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import React, { + createContext, + useContext, + useState, + useEffect, + useMemo, + useCallback, + useRef, +} from 'react'; import { toastError, toastSuccess } from '../toasts'; import Model, { getProviderMetadata, @@ -32,7 +40,11 @@ import { } from './ui/dialog'; import { Button } from './ui/button'; import { notifySessionToolsChanged } from '../utils/sessionToolEvents'; -import { announceSessionBinding } from '../utils/sessionBindingSync'; +import { + announceAppModelSelection, + announceSessionBinding, + subscribeAppModelSelectionChanges, +} from '../utils/sessionBindingSync'; // titles export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup'; @@ -42,7 +54,29 @@ export const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config. Check your conf // success const CHANGE_MODEL_TOAST_TITLE = 'Model changed'; -const SWITCH_MODEL_SUCCESS_MSG = 'Switched models'; + +/** + * What the success toast says a switch changed. + * + * It used to say "Switched models — using X from Y" whatever had moved, which + * was how a switch made in one chat could quietly become the model every new + * chat started on. A switch now lands in one of three places (see + * `ChangeModelOptions`), and the toast names the one it landed in. + */ +export function switchedModelMessage( + label: string, + source: string, + scope: { chat: boolean; newChats: boolean } +): string { + const using = `${label} from ${source}`; + if (scope.chat && scope.newChats) { + return `This chat, and new chats in every window, now use ${using}.`; + } + if (scope.chat) { + return `This chat now uses ${using}. Other chats, and new ones, are unchanged.`; + } + return `New chats in every window now start on ${using}. Existing chats keep their own model.`; +} /** * Issue #56 DR-16. The one refusal in this feature addressed to the USER rather @@ -71,19 +105,66 @@ export const NO_USER_PROOF_TOAST_MSG = */ export type ModelConfigStatus = 'loading' | 'ready'; +/** + * The app-wide selection — `BIOROUTER_PROVIDER` / `BIOROUTER_MODEL` — as the + * daemon holds it. This is exactly what `/agent/start` binds a new chat to + * (`configured_new_session_provider`), and nothing else about it is implied: an + * existing chat runs on its own session row. `null` is "not set". + */ +export interface AppModelSelection { + provider: string | null; + model: string | null; +} + +/** + * Where a model switch lands. + * + * ⚠ **A switch made from inside a chat changes THAT CHAT, and nothing else, + * unless the user says otherwise.** Until 2026-09-11 it also rewrote the + * app-wide default, silently: provider QA F bound Claude Code in one chat for + * one check, and the next chat it opened came up public. That is the coupling + * `docs/security/privacy-tiers.md` §14.3 P4 asked to be undone — "pick Versa + * once in a scratch chat privatises not one session but every session created + * afterwards", and the mirror image makes every new chat public. The coupling is + * now the explicit opt-in below, offered in the dialog where the choice is made. + * + * A switch with no chat (Home's composer, a chat not yet started, Settings → + * Models, onboarding) has only one thing it can change — the model new chats + * start on — so it always does, and its dialog says so. + */ +export interface ChangeModelOptions { + /** Also make this the model every new chat starts on, in every window. */ + alsoForNewChats?: boolean; +} + interface ModelAndProviderContextType { currentModel: string | null; currentProvider: string | null; modelConfigStatus: ModelConfigStatus; currentModelSupportsVision: boolean; currentModelSupportedInputMimeTypes: string[] | null; - changeModel: (sessionId: string | null, model: Model) => Promise; + changeModel: ( + sessionId: string | null, + model: Model, + options?: ChangeModelOptions + ) => Promise; getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>; getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>; getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>; getCurrentModelDisplayName: () => Promise; getCurrentProviderDisplayName: () => Promise; // Gets provider display name from subtext refreshCurrentModelAndProvider: () => Promise; + /** + * F3. Re-read the app-wide selection from the daemon and state it, now. + * + * Resolves with what was read, or `null` when the daemon could not answer — + * in which case nothing on screen changed, because a failed read is not + * evidence that nothing is configured. A pure read: unlike the mount-time + * {@link refreshCurrentModelAndProvider}, it never seeds the bundled default, + * so neither a window gaining focus nor another window's announcement can + * write config. + */ + syncAppModelSelection: () => Promise; } interface ModelAndProviderProviderProps { @@ -209,6 +290,37 @@ export const ModelAndProviderProvider: React.FC = const [llamaWarmupDialog, setLlamaWarmupDialog] = useState(null); const { read, getProviders, refreshConfig } = useConfig(); + /** + * F3 — the order in which statements of the app-wide selection may land. + * + * Three things now set `currentModel`/`currentProvider`: the mount read, a + * re-read (another window's announcement, this window regaining focus), and + * this window's own switch. Reads are async and overlap, so each takes a + * ticket when it is ISSUED and publishes only if no statement issued after it + * has already been published — a read that left before a switch landed cannot + * come back afterwards and restore the model the user switched away from. + * + * ⚠ Compared against what was last APPLIED, never against what was last + * issued: a newer read that FAILS publishes nothing, and must not thereby + * condemn an older one that succeeded (`docs/desktop-ui/renderer-testing-traps.md`, + * "Newest issued is the wrong rule"). + */ + const selectionIssued = useRef(0); + const selectionApplied = useRef(0); + + const takeSelectionTicket = useCallback(() => ++selectionIssued.current, []); + + const publishSelection = useCallback( + (ticket: number, model: string | null, provider: string | null): boolean => { + if (ticket < selectionApplied.current) return false; + selectionApplied.current = ticket; + setCurrentModel(model); + setCurrentProvider(provider); + return true; + }, + [] + ); + /** * Invalidate ConfigContext's cached snapshot after a write that bypassed it. * @@ -319,9 +431,12 @@ export const ModelAndProviderProvider: React.FC = }, [llamaWarmupDialog]); const changeModel = useCallback( - async (sessionId: string | null, model: Model) => { + async (sessionId: string | null, model: Model, options?: ChangeModelOptions) => { const modelName = model.name; const providerName = model.provider; + // See `ChangeModelOptions`: from a chat, the app-wide default moves only + // when the user asked for it; with no chat, it is the only thing to move. + const setsNewChatDefault = !sessionId || options?.alsoForNewChats === true; let phase = 'agent'; try { @@ -371,11 +486,13 @@ export const ModelAndProviderProvider: React.FC = // differs from the app-wide selection, would keep naming the model the // user just switched away from. // - // ⚠ **Here, not below.** This lands BEFORE `setConfigProvider` and - // before `setCurrentProvider`/`setCurrentModel`, so in the only render - // where the two can disagree the ROW holds the new binding and the - // selection still holds the old one. Announcing after the selection - // moved would invert that window and flash the old model. + // ⚠ **Here, not below.** When this switch moves the new-chat default + // as well, this lands BEFORE `setConfigProvider` and before the + // selection is published, so in the only render where the two can + // disagree the ROW holds the new binding and the selection still + // holds the old one. Announcing after the selection moved would invert + // that window and flash the old model. (A switch that moves only this + // chat never moves the selection, and the row alone is the change.) // // ⚠ And only after `updateAgentProvider` RESOLVED: a refusal (Gate A's // 409 for a public model on a private chat) throws past this line, and @@ -388,24 +505,34 @@ export const ModelAndProviderProvider: React.FC = }); } - phase = 'config'; - await setConfigProvider({ - body: { - provider: providerName, - model: modelName, - }, - headers: await userActionHeaders(), - throwOnError: true, - }); + if (setsNewChatDefault) { + phase = 'config'; + await setConfigProvider({ + body: { + provider: providerName, + model: modelName, + }, + headers: await userActionHeaders(), + throwOnError: true, + }); - setCurrentProvider(providerName); - setCurrentModel(modelName); - setModelConfigStatus('ready'); - await refreshCachedConfig(); + // A statement like any read, and ticketed like one: a read that left + // before this write landed is older than what is now on screen, and + // must not be allowed to come back and restore the previous model. + publishSelection(takeSelectionTicket(), modelName, providerName); + setModelConfigStatus('ready'); + await refreshCachedConfig(); + // F3. Every other window — and each one's next new chat — follows. + // After the write, never before: a receiver re-reads the daemon. + announceAppModelSelection(); + } toastSuccess({ title: CHANGE_MODEL_TOAST_TITLE, - msg: `${SWITCH_MODEL_SUCCESS_MSG} — using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`, + msg: switchedModelMessage(model.alias ?? modelName, model.subtext ?? providerName, { + chat: !!sessionId, + newChats: setsNewChatDefault, + }), }); // Issue #56 DR-26 at the BIND surface. Binding a model covered by one // institution's agreements into a chat holding another institution's @@ -458,7 +585,7 @@ export const ModelAndProviderProvider: React.FC = return false; } }, - [prepareLlamaModel, refreshCachedConfig] + [prepareLlamaModel, refreshCachedConfig, publishSelection, takeSelectionTicket] ); const getFallbackModelAndProvider = useCallback(async () => { @@ -481,6 +608,9 @@ export const ModelAndProviderProvider: React.FC = }); // Same API-mediated write, same stale cache (#52). await refreshCachedConfig(); + // F3. A seeded default is a new-chat default like any other; a window + // that mounted before it was written would otherwise go on naming none. + announceAppModelSelection(); } catch (error) { console.error('[getFallbackModelAndProvider] Failed to write to config', error); } @@ -558,10 +688,10 @@ export const ModelAndProviderProvider: React.FC = }, [read, getCurrentModelAndProviderForDisplay]); const refreshCurrentModelAndProvider = useCallback(async () => { + const ticket = takeSelectionTicket(); try { const { model, provider } = await getCurrentModelAndProvider(); - setCurrentModel(model); - setCurrentProvider(provider); + publishSelection(ticket, model, provider); } catch (_error) { console.error('Failed to refresh current model and provider:', _error); } finally { @@ -570,7 +700,75 @@ export const ModelAndProviderProvider: React.FC = // would park every consumer on a spinner that never resolves. setModelConfigStatus('ready'); } - }, [getCurrentModelAndProvider]); + }, [getCurrentModelAndProvider, publishSelection, takeSelectionTicket]); + + const syncAppModelSelection = useCallback(async (): Promise => { + const ticket = takeSelectionTicket(); + let fresh: AppModelSelection; + try { + const [model, provider] = await Promise.all([ + read('BIOROUTER_MODEL', false), + read('BIOROUTER_PROVIDER', false), + ]); + // `/config/read` answers an unset key with `null`, and a failed read — + // a 500, an unreachable daemon — resolves with no body at all, which the + // generated client hands back as `undefined`. Only the first is a fact. + if (model === undefined || provider === undefined) return null; + fresh = { + model: typeof model === 'string' && model ? model : null, + provider: typeof provider === 'string' && provider ? provider : null, + }; + } catch (error) { + console.error('Failed to re-read the app-wide model selection:', error); + return null; + } + publishSelection(ticket, fresh.model, fresh.provider); + return fresh; + }, [read, publishSelection, takeSelectionTicket]); + + /** + * F3 — follow the app-wide selection for the life of this window. + * + * Two ears, one action (a pure re-read): + * + * - **Another window, or this one's `ConfigContext`, wrote it.** Every + * renderer write of `BIOROUTER_PROVIDER`/`BIOROUTER_MODEL` announces on + * `sessionBindingSync`'s channel, which reaches every window of the app. + * - **Something outside the renderer wrote it** — `biorouter configure` in a + * terminal (the app's own included), a hand-edited `config.yaml`. Nothing + * announces those; the daemon's config cache is keyed on the file's stamp, + * so `/agent/start` binds them at once. The window is re-read when it + * regains focus or becomes visible, which is when a user who made the + * change elsewhere comes back to act on it. The send path checks once more + * (`useConfirmNewChatModel`), because a terminal docked INSIDE the window + * never takes the window's focus away. + * + * ⚠ Mount-once, with the handler read through a ref at call time — the + * subscription must not be torn down and re-made because a callback's + * identity moved (the same rule `ConfigContext`'s catalogue subscription + * records, for the same reason: the subscription belongs to the mount). + */ + const syncAppModelSelectionRef = useRef(syncAppModelSelection); + useEffect(() => { + syncAppModelSelectionRef.current = syncAppModelSelection; + }, [syncAppModelSelection]); + + useEffect(() => { + const resync = () => { + void syncAppModelSelectionRef.current(); + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') resync(); + }; + const unsubscribe = subscribeAppModelSelectionChanges(resync); + window.addEventListener('focus', resync); + document.addEventListener('visibilitychange', onVisibility); + return () => { + unsubscribe(); + window.removeEventListener('focus', resync); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, []); // Derive vision support whenever the active model/provider changes useEffect(() => { @@ -658,6 +856,7 @@ export const ModelAndProviderProvider: React.FC = getCurrentModelDisplayName, getCurrentProviderDisplayName, refreshCurrentModelAndProvider, + syncAppModelSelection, }), [ currentModel, @@ -672,6 +871,7 @@ export const ModelAndProviderProvider: React.FC = getCurrentModelDisplayName, getCurrentProviderDisplayName, refreshCurrentModelAndProvider, + syncAppModelSelection, ] ); diff --git a/ui/desktop/src/components/privacy/useConfirmNewChatModel.test.tsx b/ui/desktop/src/components/privacy/useConfirmNewChatModel.test.tsx new file mode 100644 index 000000000..bf14f7ebd --- /dev/null +++ b/ui/desktop/src/components/privacy/useConfirmNewChatModel.test.tsx @@ -0,0 +1,192 @@ +import { act, renderHook } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + NEW_CHAT_MODEL_CHANGED_TITLE, + newChatModelChangedMessage, + useConfirmNewChatModel, +} from './useConfirmNewChatModel'; + +/** + * F3 — the last look before a composer creates a new chat. + * + * The cross-window suite (`ModelAndProviderContext.crossWindow.test.tsx`) drives + * this hook end to end against the real context. What is pinned here is the + * part that must NOT refuse: a check that blocked sends on a guess would be + * routed around, which is worse than the stale label it exists to catch. + */ + +const mocks = vi.hoisted(() => ({ + state: { + currentModel: 'gpt-5.5-2026-04-24' as string | null, + currentProvider: 'versa_azure' as string | null, + modelConfigStatus: 'ready' as 'ready' | 'loading', + }, + syncAppModelSelection: vi.fn(), + getProviders: vi.fn(), + toastWarning: vi.fn(), +})); + +vi.mock('../ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + ...mocks.state, + syncAppModelSelection: mocks.syncAppModelSelection, + }), +})); + +vi.mock('../ConfigContext', () => ({ + useConfig: () => ({ getProviders: mocks.getProviders }), +})); + +vi.mock('../../toasts', () => ({ toastWarning: mocks.toastWarning })); + +const confirm = async () => { + const { result } = renderHook(() => useConfirmNewChatModel()); + let answer: boolean | undefined; + await act(async () => { + answer = await result.current(); + }); + return answer; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.state.currentModel = 'gpt-5.5-2026-04-24'; + mocks.state.currentProvider = 'versa_azure'; + mocks.state.modelConfigStatus = 'ready'; + mocks.getProviders.mockResolvedValue([ + { + name: 'claude_code', + metadata: { name: 'claude_code', display_name: 'Claude Code' }, + resolved_tier: 'public', + }, + ]); +}); + +describe('useConfirmNewChatModel', () => { + it('proceeds when the chip already states what a new chat binds', async () => { + mocks.syncAppModelSelection.mockResolvedValue({ + provider: 'versa_azure', + model: 'gpt-5.5-2026-04-24', + }); + expect(await confirm()).toBe(true); + expect(mocks.toastWarning).not.toHaveBeenCalled(); + }); + + it('refuses a known mismatch, naming the new model, its provider and its tier', async () => { + mocks.syncAppModelSelection.mockResolvedValue({ + provider: 'claude_code', + model: 'claude-fable-5-1', + }); + expect(await confirm()).toBe(false); + expect(mocks.toastWarning).toHaveBeenCalledWith({ + title: NEW_CHAT_MODEL_CHANGED_TITLE, + msg: + 'New chats now start on claude-fable-5-1 (Claude Code, a public model), not ' + + 'gpt-5.5-2026-04-24, which this window was still showing. Your message is back in the ' + + 'composer, and the model shown below is the one it will use.', + }); + }); + + /** No evidence is not a mismatch: `createSession` reports a dead daemon. */ + it('proceeds when the daemon could not be read', async () => { + mocks.syncAppModelSelection.mockResolvedValue(null); + expect(await confirm()).toBe(true); + expect(mocks.toastWarning).not.toHaveBeenCalled(); + }); + + /** + * The first frames after launch have no label on screen to be wrong about, + * and refusing there would block the fastest send for nothing. + */ + it('does not even look while nothing is named on screen', async () => { + mocks.state.modelConfigStatus = 'loading'; + mocks.state.currentModel = null; + mocks.state.currentProvider = null; + expect(await confirm()).toBe(true); + expect(mocks.syncAppModelSelection).not.toHaveBeenCalled(); + }); + + it('still refuses when the catalog cannot name the new provider', async () => { + mocks.getProviders.mockRejectedValue(new Error('catalog down')); + mocks.syncAppModelSelection.mockResolvedValue({ provider: 'codex', model: 'gpt-6-astra' }); + expect(await confirm()).toBe(false); + expect(mocks.toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ + msg: expect.stringContaining('New chats now start on gpt-6-astra (codex), not'), + }) + ); + }); +}); + +describe('newChatModelChangedMessage', () => { + it('says so when no model is set for new chats any more', () => { + expect( + newChatModelChangedMessage( + 'gpt-5.5-2026-04-24', + { provider: null, model: null }, + null, + undefined + ) + ).toBe( + 'No model is set for new chats any more — this window was still showing ' + + 'gpt-5.5-2026-04-24. Your message is back in the composer.' + ); + }); + + it('states a private tier as plainly as a public one', () => { + expect( + newChatModelChangedMessage( + 'claude-fable-5-1', + { provider: 'versa_azure', model: 'gpt-5.5-2026-04-24' }, + 'Versa API Azure', + 'private' + ) + ).toContain('(Versa API Azure, a private model)'); + }); + + /** An unresolved tier is not "public", and it is not said to be. */ + it('says nothing about a tier it could not resolve', () => { + expect( + newChatModelChangedMessage('x', { provider: 'ollama', model: 'llama' }, 'Ollama', undefined) + ).toContain('(Ollama)'); + }); +}); + +/** + * ⚠ Both composers, and before `createSession`. Home (`Hub.tsx`) renders its + * OWN `ChatInput`, not `BaseChat`'s — the app launches onto it — so a check + * wired into one of them only is absent from half the ways a chat is started. + * And a check placed after + * `createSession` would run once `/agent/start` had already bound the chat. + * jsdom mounts neither surface cheaply, so this is pinned at the source. + */ +describe('both new-chat composers look before they create', () => { + const source = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); + const CHECK = 'if (!(await confirmNewChatModel())) return false;'; + + /** + * Each composer's first side effect of a send, which the check must precede: + * a refused send has to leave everything as it found it. Home clears the + * pending extension overrides as it reads them; a chat marks itself as + * creating, which blocks the composer. + */ + it.each([ + ['Hub.tsx', 'clearExtensionOverrides();'], + ['BaseChat.tsx', 'setIsCreatingSession(true);'], + ])('%s checks the model before createSession and before %s', (file, firstSideEffect) => { + const text = source(file); + const check = text.indexOf(CHECK); + expect(check).toBeGreaterThan(-1); + // Exactly one check per composer — a second one would be a second policy. + expect(text.indexOf(CHECK, check + CHECK.length)).toBe(-1); + + const sideEffect = text.indexOf(firstSideEffect, check); + const create = text.indexOf('await createSession(', check); + expect(sideEffect).toBeGreaterThan(check); + expect(create).toBeGreaterThan(sideEffect); + // And nothing of the kind slipped in ahead of it. + expect(text.slice(Math.max(0, check - 400), check)).not.toContain(firstSideEffect); + }); +}); diff --git a/ui/desktop/src/components/privacy/useConfirmNewChatModel.ts b/ui/desktop/src/components/privacy/useConfirmNewChatModel.ts new file mode 100644 index 000000000..7322ed60b --- /dev/null +++ b/ui/desktop/src/components/privacy/useConfirmNewChatModel.ts @@ -0,0 +1,101 @@ +import { useCallback } from 'react'; +import { useConfig } from '../ConfigContext'; +import { useModelAndProvider, type AppModelSelection } from '../ModelAndProviderContext'; +import { readResolvedProviderTier } from './useBoundProviderTier'; +import { toastWarning } from '../../toasts'; +import type { ProviderTier } from '../../api/types.gen'; + +export const NEW_CHAT_MODEL_CHANGED_TITLE = 'Message not sent'; + +/** + * The toast for a send refused because the model on screen was not the model a + * new chat would have started on. + * + * Written for a person, and it states the tier in words because the tier is + * the reason this check exists: a window reading "Private model, UCSF" must not + * start a chat on a public model with nothing said. It names what is true now, + * what the window had been showing, and where the message went — and stops. + */ +export function newChatModelChangedMessage( + shownModel: string, + fresh: AppModelSelection, + freshProviderName: string | null, + freshTier: ProviderTier | undefined +): string { + if (!fresh.model || !fresh.provider) { + return ( + `No model is set for new chats any more — this window was still showing ${shownModel}. ` + + 'Your message is back in the composer.' + ); + } + const tier = + freshTier === 'private' + ? ', a private model' + : freshTier === 'public' + ? ', a public model' + : ''; + return ( + `New chats now start on ${fresh.model} (${freshProviderName ?? fresh.provider}${tier}), ` + + `not ${shownModel}, which this window was still showing. Your message is back in the ` + + 'composer, and the model shown below is the one it will use.' + ); +} + +/** + * F3 — the last look before a composer creates a NEW chat. + * + * `/agent/start` binds a new chat to whatever `BIOROUTER_PROVIDER` / + * `BIOROUTER_MODEL` say on the daemon at that instant, and it takes no provider + * of its own. Everything the composer states about the model — name, gauge, + * cost, the "Private model, UCSF" padlock — comes from this window's copy of + * those two keys. `ModelAndProviderContext` keeps that copy current: every + * window hears every renderer write, and re-reads on focus. What it cannot hear + * is a write from outside the renderer while this window keeps its focus — a + * `biorouter configure` in the terminal docked inside this very window is the + * ordinary case. So the send path asks once more, at the only moment the answer + * decides anything. + * + * Resolves `true` to proceed. `false` means the window was stale: the fresh + * selection is already on screen (the re-read published it), a toast says what + * changed, and the caller returns `false` so `ChatInput` puts the text back. + * + * ⚠ It refuses only a KNOWN mismatch. Nothing named on screen yet (the first + * frames after launch, or no model at all) and a read that failed both proceed + * exactly as before: the first had no label to be wrong about, and the second + * has no evidence — `createSession` reports a daemon that cannot answer. + * + * ⚠ Not a gate. The daemon classifies the chat by what it binds, correctly, + * whatever this does; this only keeps the human's last look honest. + */ +export function useConfirmNewChatModel(): () => Promise { + const { currentModel, currentProvider, modelConfigStatus, syncAppModelSelection } = + useModelAndProvider(); + const { getProviders } = useConfig(); + + return useCallback(async () => { + if (modelConfigStatus !== 'ready' || !currentProvider || !currentModel) return true; + + const fresh = await syncAppModelSelection(); + if (!fresh) return true; + if (fresh.provider === currentProvider && fresh.model === currentModel) return true; + + let providerName: string | null = null; + let tier: ProviderTier | undefined; + if (fresh.provider) { + try { + const row = (await getProviders(false)).find( + (candidate) => candidate.name === fresh.provider + ); + providerName = row?.metadata?.display_name ?? null; + tier = readResolvedProviderTier(row); + } catch { + // The sentence is still complete and still true without either. + } + } + toastWarning({ + title: NEW_CHAT_MODEL_CHANGED_TITLE, + msg: newChatModelChangedMessage(currentModel, fresh, providerName, tier), + }); + return false; + }, [currentModel, currentProvider, modelConfigStatus, syncAppModelSelection, getProviders]); +} diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.pinned.test.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.pinned.test.tsx index f8a2a93b1..5a3bacb0c 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.pinned.test.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.pinned.test.tsx @@ -1,6 +1,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import ModelsBottomBar, { CHAT_KEEPS_ITS_MODEL_NOTE } from './ModelsBottomBar'; +import ModelsBottomBar, { + CHAT_KEEPS_ITS_MODEL_NOTE, + NEW_CHATS_MODEL_HEADING, + NEW_CHATS_MODEL_NOTE, +} from './ModelsBottomBar'; import { __resetDisclosureStoreForTests } from '../../../privacy/disclosureCopy'; /** @@ -174,3 +178,38 @@ describe('a chat bound to something other than the app-wide selection', () => { expect(screen.queryByTestId('chat-binding-note')).toBeNull(); }); }); + +/** + * F3 — where there is no chat yet (Home, a chat not started), the chip names + * the APP-WIDE selection, and a switch from it changes that selection for every + * window. The dropdown says whose model it is and how far a change reaches, + * beside the control that makes the change. + */ +describe('the chip where there is no chat yet', () => { + const renderSessionless = () => + render( + + ); + + const openDropdown = async () => { + await screen.findByRole('button', { name: /Current model:/ }); + fireEvent.pointerDown(screen.getByLabelText(/Current model/), { button: 0, ctrlKey: false }); + }; + + it('heads its dropdown as the model for new chats, reaching every window', async () => { + renderSessionless(); + await openDropdown(); + + expect(await screen.findByText(NEW_CHATS_MODEL_HEADING)).toBeInTheDocument(); + expect(screen.getByTestId('new-chats-model-note')).toHaveTextContent(NEW_CHATS_MODEL_NOTE); + expect(screen.queryByText('Current model')).toBeNull(); + }); + + it('keeps "Current model", and no such line, in a chat', async () => { + renderBar(undefined); + await openDropdown(); + + expect(await screen.findByText('Current model')).toBeInTheDocument(); + expect(screen.queryByTestId('new-chats-model-note')).toBeNull(); + }); +}); diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 31813e60a..743710bd3 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -45,6 +45,19 @@ import type { PinnedModelView } from '../../../../hooks/chatStreamStore'; export const CHAT_KEEPS_ITS_MODEL_NOTE = 'This chat keeps the model it was last set to. A model chosen elsewhere applies to new chats.'; +/** + * F3 — the heading and line this chip's dropdown carries where there is no chat + * yet (Home, a chat not started). + * + * There the chip names the APP-WIDE selection — the pair `/agent/start` will + * bind — and switching from it changes that pair for every window. "Current + * model" read as a property of this screen; the heading says whose model it is, + * and the line says how far a change reaches, beside the control that makes it. + */ +export const NEW_CHATS_MODEL_HEADING = 'Model for new chats'; +export const NEW_CHATS_MODEL_NOTE = + 'New chats in every window start on this model. Existing chats keep their own.'; + interface ModelsBottomBarProps { sessionId: string | null; dropdownRef: React.RefObject; @@ -518,11 +531,21 @@ export default function ModelsBottomBar({
-
Current model
+
+ {sessionId ? 'Current model' : NEW_CHATS_MODEL_HEADING} +
{shownModelName} {shownProviderName && ` · ${shownProviderName}`}
+ {!sessionId && ( +
+ {NEW_CHATS_MODEL_NOTE} +
+ )} {/* Under the heading "Current model", so it must be about the model. It used to be `privacyLine`. */} {modelTierWords && ( diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx index eb5f44bf6..b7febdf6d 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx @@ -278,11 +278,16 @@ describe('SwitchModelModal — pre-flight, not post-refusal', () => { fireEvent.click(confirm); await waitFor(() => expect(mocks.changeModel).toHaveBeenCalledTimes(1)); - expect(mocks.changeModel).toHaveBeenCalledWith('s1', { - name: 'gpt-5.6-sol', - provider: 'codex', - subtext: 'Codex', - }); + expect(mocks.changeModel).toHaveBeenCalledWith( + 's1', + { + name: 'gpt-5.6-sol', + provider: 'codex', + subtext: 'Codex', + }, + // Opened from a chat, the box is offered and starts unticked. + { alsoForNewChats: false } + ); }); // The control case: a public provider has no affiliation at all, so the row is diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx index edaf1894e..58b25cd51 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx @@ -1,6 +1,12 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SwitchModelModal } from './SwitchModelModal'; +import { + ALSO_FOR_NEW_CHATS_HINT, + ALSO_FOR_NEW_CHATS_LABEL, + SWITCH_SCOPE_NEW_CHATS, + SWITCH_SCOPE_THIS_CHAT, + SwitchModelModal, +} from './SwitchModelModal'; const mocks = vi.hoisted(() => ({ getProviders: vi.fn(), @@ -221,3 +227,120 @@ describe('SwitchModelModal switch feedback', () => { expect(unhandled).toEqual([]); }); }); + +/** + * F3 / `docs/security/privacy-tiers.md` §14.3 P4 — the dialog says what a + * switch changes, before the user commits. + * + * Until 2026-09-11 a switch made from a chat's composer also rewrote the model + * every new chat starts on, in every window, with nothing on screen to say so: + * provider QA F bound Claude Code in one chat for one check, and the next chat + * it opened came up public. The coupling is now an explicit, unticked box, and + * the dialog opened with no chat says plainly that it is the app-wide choice. + */ +describe('SwitchModelModal — what the switch changes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getProviders.mockResolvedValue([ + { + name: 'versa_azure', + is_configured: true, + provider_type: 'Institutional', + metadata: { + name: 'versa_azure', + display_name: 'Versa API Azure', + default_model: 'gpt-5.5-2026-04-24', + known_models: [{ name: 'gpt-5.5-2026-04-24' }], + allows_unlisted_models: false, + config_keys: [], + }, + }, + ]); + mocks.getProviderModels.mockResolvedValue(['gpt-5.5-2026-04-24']); + mocks.read.mockResolvedValue(''); + mocks.changeModel.mockResolvedValue(true); + }); + + const renderModal = (sessionId: string | null) => + render( + + ); + + const confirm = () => + waitFor(() => { + const found = screen.getAllByRole('button').find((el) => el.textContent === 'Select model'); + if (!found) throw new Error('Select model button not rendered'); + return found; + }); + + /** Let the provider list land, and the model list after it, inside act. */ + const settle = async () => { + await waitFor(() => + expect(screen.getByTestId('provider-select')).toHaveTextContent('versa_azure') + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + it('from a chat, says it switches this chat and offers new chats as an unticked box', async () => { + renderModal('s-1'); + + expect(screen.getByText(SWITCH_SCOPE_THIS_CHAT)).toBeInTheDocument(); + const box = screen.getByRole('checkbox', { name: new RegExp(ALSO_FOR_NEW_CHATS_LABEL) }); + expect(box).not.toBeChecked(); + expect(screen.getByText(ALSO_FOR_NEW_CHATS_HINT)).toBeInTheDocument(); + await settle(); + }); + + it('leaves new chats alone unless the box is ticked', async () => { + renderModal('s-1'); + fireEvent.click(await confirm()); + + await waitFor(() => expect(mocks.changeModel).toHaveBeenCalledTimes(1)); + expect(mocks.changeModel).toHaveBeenCalledWith( + 's-1', + expect.objectContaining({ name: 'gpt-5.5-2026-04-24', provider: 'versa_azure' }), + { alsoForNewChats: false } + ); + }); + + it('carries a ticked box through to the switch', async () => { + renderModal('s-1'); + fireEvent.click(screen.getByRole('checkbox', { name: new RegExp(ALSO_FOR_NEW_CHATS_LABEL) })); + fireEvent.click(await confirm()); + + await waitFor(() => expect(mocks.changeModel).toHaveBeenCalledTimes(1)); + expect(mocks.changeModel).toHaveBeenCalledWith( + 's-1', + expect.objectContaining({ name: 'gpt-5.5-2026-04-24' }), + { alsoForNewChats: true } + ); + }); + + /** + * With no chat — Home, a chat not started, Settings → Models, onboarding — + * the only thing a switch can change is the model new chats start on, so + * there is no box to offer, and the description says how far it reaches. + */ + it('with no chat, says it sets the model new chats start on in every window', async () => { + renderModal(null); + + expect(screen.getByText(SWITCH_SCOPE_NEW_CHATS)).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + + await settle(); + fireEvent.click(await confirm()); + await waitFor(() => expect(mocks.changeModel).toHaveBeenCalledTimes(1)); + expect(mocks.changeModel).toHaveBeenCalledWith( + null, + expect.objectContaining({ name: 'gpt-5.5-2026-04-24' }) + ); + }); +}); diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 5fead65d5..7cc37e1bc 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -10,6 +10,7 @@ import { DialogTitle, } from '../../../ui/dialog'; import { Button } from '../../../ui/button'; +import { Checkbox } from '../../../ui/Checkbox'; import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants'; import { Input } from '../../../ui/input'; import { Select } from '../../../ui/Select'; @@ -135,6 +136,23 @@ const modelOptionSearchText = (option: ModelOption) => const PUBLIC_MODEL_IN_PRIVATE_CHAT = 'Unavailable: this is a private chat, so only private models may run in it'; +/** + * F3 / privacy-tiers §14.3 P4 — what a switch from THIS dialog changes, said in + * the dialog, before the user commits. + * + * Opened from a chat, the dialog changes that chat and nothing else unless the + * box below the pickers is ticked; opened with no chat — Home's composer, a chat + * not started yet, Settings → Models, onboarding — the only thing it can change + * is the model new chats start on, in every window. The old description, "for + * your chats", fitted neither, and the switch it described did both. + */ +export const SWITCH_SCOPE_THIS_CHAT = 'Select a provider and model for this chat.'; +export const SWITCH_SCOPE_NEW_CHATS = + 'Select the provider and model new chats start on, in every window. Existing chats keep their own model.'; +export const ALSO_FOR_NEW_CHATS_LABEL = 'Also use for new chats'; +export const ALSO_FOR_NEW_CHATS_HINT = + 'New chats in every window will start on this model. Left unticked, only this chat changes.'; + const renderModelOptionLabel = ( rawOption: unknown, meta: { context: 'menu' | 'value' }, @@ -380,6 +398,12 @@ export const SwitchModelModal = ({ */ const [switching, setSwitching] = useState(false); const [submitError, setSubmitError] = useState(null); + /** + * P4's "Also make this my default for new chats", as an explicit and + * unticked box. Only offered from a chat: with no chat there is nothing else + * the switch could change. See `ChangeModelOptions`. + */ + const [alsoForNewChats, setAlsoForNewChats] = useState(false); const handleSubmit = async () => { // The button below is already disabled on a browser surface; this is the @@ -412,7 +436,9 @@ export const SwitchModelModal = ({ modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model; } - const changed = await changeModel(sessionId, modelObj); + const changed = sessionId + ? await changeModel(sessionId, modelObj, { alsoForNewChats }) + : await changeModel(null, modelObj); if (!changed) { // `changeModel` has already raised the toast that explains *why* — a // privacy barrier, a missing user proof, a provider failure. This says @@ -699,7 +725,9 @@ export const SwitchModelModal = ({ {hostManaged ? HOST_MANAGED_MODEL_TITLE - : 'Select a provider and model to use for your chats.'} + : sessionId + ? SWITCH_SCOPE_THIS_CHAT + : SWITCH_SCOPE_NEW_CHATS} @@ -909,6 +937,30 @@ export const SwitchModelModal = ({ )}
+ {/* + P4's opt-in, directly above the confirm it modifies. Unticked by + default: a switch made in a chat is a statement about that chat, and a + public model chosen for one scratch chat must not become what every + new chat — in every window — silently starts on. + */} + {sessionId && !hostManaged && ( +
+ setAlsoForNewChats(event.target.checked)} + disabled={switching} + className="mt-0.5" + /> + +
+ )} + {submitError && (
Date: Fri, 11 Sep 2026 11:54:54 -0700 Subject: [PATCH 3/5] docs: model selection across windows (F3), and P4's decoupling in the ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/desktop-ui/model-selection-across-windows.md: the two facts a model chip can state (a chat's binding vs the app-wide selection /agent/start binds), what a switch changes from each surface and why (privacy-tiers §14.3 P4), how each window stays current (announcements, focus re-reads, ticketed reads), the last look before a new chat, what it does not cover, the tests, and how to check it in the running app. Indexed in docs/desktop-ui/README.md. privacy-tiers.md's "What shipped" ledger gains a dated line for P4, so §14.3 no longer reads as open. --- docs/desktop-ui/README.md | 1 + .../model-selection-across-windows.md | 147 ++++++++++++++++++ docs/security/privacy-tiers.md | 7 + 3 files changed, 155 insertions(+) create mode 100644 docs/desktop-ui/model-selection-across-windows.md diff --git a/docs/desktop-ui/README.md b/docs/desktop-ui/README.md index ff74729d8..612f94e24 100644 --- a/docs/desktop-ui/README.md +++ b/docs/desktop-ui/README.md @@ -34,6 +34,7 @@ arrived looking for one of those, leave now. | [Where a generated artifact is displayed](artifact-display-surfaces.md) | The rule that a figure, an app card or any generated artifact has exactly ONE display surface — the artifact side panel — on all three transcript surfaces (live chat, saved session, shared session), and why that is enforced by a required prop rather than by convention. Covers what the removed inline renderer actually cost (a second CSP, a second action channel, a second resize contract, a fabricated session id), what was deleted with it, and what deliberately stayed. Current. | | [The preview panel](preview-panel/README.md) | The working documents for expanding the artifact side panel: a measured survey of every render branch, image list and guard as it stands, and the plan to widen it along five axes — more image formats, the Office gap around the renderers that already ship, live websites in their own native view, an annotation channel back into the chat, and agent access to what the panel is showing. Plan **executed**; the implementation record covers what shipped, what was verified against real Electron, and what is still open. | | [The settings visual vocabulary](settings-visual-vocabulary.md) | The ten rules the Settings view (Models, Chat, App), the chat-history surfaces, the Scheduler and the component views (Workflows, Extensions, Skills, Built apps) are built to, and the primitives they lean on: a row's fill never depends on its state, rows are direct children of their list, one note shape with a ceiling on it, type roles rather than sizes, the button ladder, and one shared page header whose actions sit on their own line under the description. Covers why six of them are asserted at the source rather than in a render test — jsdom never runs Tailwind, and two of the defects only appear in the cascade. Current. | +| [Model selection across windows](model-selection-across-windows.md) | What keeps the composer's model chip — name, gauge, cost and privacy padlock — equal to what the next turn will run on in every window (provider-QA F3): a chat's own binding versus the app-wide selection `/agent/start` binds, the rule that a switch made in a chat changes that chat unless "Also use for new chats" is ticked (privacy-tiers §14.3 P4), the nudge every renderer write announces across windows, the ticketed re-reads, and the last look before a new chat is created. Current. | | [The provider catalog](provider-catalog.md) | The one surface listing every provider the daemon serves: three tabs carrying §14.5's privacy taxonomy, institutions named from the daemon's affiliation payload rather than from a literal, AI agents ahead of the API providers, and a default tab computed from the bound provider / a subscription-ready CLI / Local. Also the first-run screen, which is the same component in `mode="onboarding"`, the `BIOROUTER_ONBOARDING_SKIPPED` escape from it, and the composer's no-model state that makes that escape honest. Current. | | [The settings visual vocabulary](settings-visual-vocabulary.md) | The nine rules the Settings view (Models, Chat, App) is built to, and the two primitives they lean on: a row's fill never depends on its state, rows are direct children of their list, one note shape with a ceiling on it, type roles rather than sizes, and the button ladder. Covers why six of them are asserted at the source rather than in a render test — jsdom never runs Tailwind, and two of the defects only appear in the cascade. Current. | | [Diverge behavior checklist](diverge-behavior-checklist.md) | A catalog of 68 user actions for Diverge — the feature that branches a conversation into a new session — each paired with the behavior BioRouter must exhibit, serving as both a manual QA script and the spec the automated tests encode. Current; last revised 2026-07-18, when the dashboard-canvas items were deleted alongside dashboard mode itself. | diff --git a/docs/desktop-ui/model-selection-across-windows.md b/docs/desktop-ui/model-selection-across-windows.md new file mode 100644 index 000000000..64170201e --- /dev/null +++ b/docs/desktop-ui/model-selection-across-windows.md @@ -0,0 +1,147 @@ +# Model selection across windows + +> **What this is.** The rules that keep the composer's model chip — its name, gauge, cost line and "Private model, UCSF" padlock — equal to what the next turn will actually run on, in every window, and the decision about what a model switch changes. +> **Status:** Current. Shipped for provider-QA finding F3 (2026-09-10) on top of `main` at `7c96d796`. +> **Audience:** developers working on the desktop renderer's model selection, the composer, or privacy tiers. + +A chat runs on one of two things, and the chip has to state the right one at the moment a +person acts on it. Provider QA measured the failure on 2026-09-10: with two windows open, +changing the model in window 1 left window 2's chip reading `gpt-5.5-2026-04-24 (Private +model, UCSF)`, and the chat window 2 started bound `claude_code` — a consumer subscription +with no BAA (business associate agreement). The privacy barrier held: the chat was classified +`public`. What failed was the label the user read before typing. + +## The two facts a chip can state + +| Fact | Where it lives | What it decides | Who states it | +|---|---|---|---| +| **A chat's binding** | the session row (`provider_name`, `model_config`), outranked by the pin a turn reports | what an existing chat's next turn runs on — Gate B rebinds from the row | the chip inside a chat that has one | +| **The app-wide selection** | `BIOROUTER_PROVIDER` / `BIOROUTER_MODEL` in `config.yaml` | what a **new** chat binds — `/agent/start` reads exactly these two keys (`configured_new_session_provider`) and accepts no provider of its own | the chip on Home and in a chat not yet started | + +`privacy/pinnedModel.ts` (`chatBinding`) and `privacy/usePinnedModel.ts` choose between the +two for a chat. This document is about keeping each one *current* — before F3 the second was +read once, when `ModelAndProviderContext` mounted, and never again. + +## What a model switch changes + +The model switcher (`SwitchModelModal`) is reached from the chip, from Settings → Models and +from onboarding, and every one of them ends in `ModelAndProviderContext.changeModel`. + +| Opened from | What the switch changes | What the dialog says | +|---|---|---| +| a chat that exists | **that chat only** — its session row, through `/agent/update_provider` | "Select a provider and model for this chat." plus an unticked **Also use for new chats** box | +| a chat, with the box ticked | that chat **and** the app-wide selection | the box's hint: new chats in every window will start on it | +| Home, a chat not yet started, Settings → Models, onboarding | the app-wide selection — the only thing there is to change | "Select the provider and model new chats start on, in every window. Existing chats keep their own model." | + +The success toast names which of the three happened (`switchedModelMessage`), and the chip's +dropdown, where there is no chat, is headed **Model for new chats** with the line "New chats +in every window start on this model. Existing chats keep their own." + +> **Why.** Until 2026-09-11 a switch made in a chat also rewrote the app-wide selection, +> silently. QA F bound Claude Code in one chat for one check, and the next chat it opened came +> up public. [`privacy-tiers.md`](../security/privacy-tiers.md) §14.3 **P4** had already asked +> for this coupling to be undone — "pick Versa once in a scratch chat privatises not one +> session but every session created afterwards" — with an explicit "also make this my default +> for new chats" control. The box is that control, and it starts unticked because a switch +> made inside a chat reads as a statement about that chat. + +## How each window stays current + +Every window of the app is its own renderer with its own `ModelAndProviderContext`, over one +daemon. + +- **Every renderer write announces.** `changeModel`, the first-run seeding of the bundled + default (`getFallbackModelAndProvider`), and `ConfigContext.upsert` / `remove` of either key + call `announceAppModelSelection` (`utils/sessionBindingSync.ts`). The last one covers the + writers that never pass through `changeModel`: the local and coding-agent onboarding cards, + Lead/Worker settings and Settings' reset. Before F3 those did not update even their own + window's chip. +- **The announcement is a nudge, not a payload.** It travels on the same `BroadcastChannel` + as the per-chat binding (`biorouter:session-binding`), shaped `{ kind: 'app-model-selection' }` + and carrying no provider and no model. Two windows' writes can be announced in the opposite + order from the one they landed in, so every receiver re-reads the daemon and ends on the + write that landed last — the one `/agent/start` will bind. +- **A window re-reads when it regains focus or becomes visible.** Nothing announces a write + made outside the renderer: `biorouter configure` in a terminal, or a hand-edited + `config.yaml`. The daemon's config cache is keyed on the file's stamp, so `/agent/start` + binds such a write at once. +- **Reads are ticketed.** The mount read, every re-read and the window's own switch each take + a ticket when issued, and one publishes only if nothing issued after it has already been + published. The comparison is against what was last *applied*, never what was last + *issued* — see [renderer testing traps](renderer-testing-traps.md). A re-read that comes + back with no body (a 500, a daemon that is restarting) changes nothing on screen: a failed + read is not evidence that nothing is configured. +- **A re-read never writes.** `syncAppModelSelection` is a pure read. Only the mount-time + `refreshCurrentModelAndProvider` may seed the bundled default, so neither a focus event nor + another window's announcement can write config. + +## The last look before a new chat + +Both composers that create a chat — Home (`Hub.tsx`) and a chat not yet started +(`BaseChat.tsx`) — call `useConfirmNewChatModel` immediately before `createSession`, ahead +of anything the send consumes. It re-reads the pair and compares it with what the chip +showed. On a mismatch it: + +1. publishes the fresh pair, so the chip, gauge, cost and padlock change; +2. raises a **Message not sent** toast naming the new model, its provider and its tier in + words ("New chats now start on claude-fable-5-1 (Claude Code, a public model), not + gpt-5.5-2026-04-24, which this window was still showing…"); +3. resolves `false`, and `ChatInput` puts the text back. + +It exists for the one write no ear hears in time: a `biorouter configure` in the terminal docked +**inside** the window, which never takes the window's focus. It refuses only a known mismatch — +nothing named on screen yet, or a read that failed, both proceed as before. It is not a gate: +the daemon classifies the chat by what it binds, whatever this check does. + +## What this does not cover + +- **The pin still outranks the row.** An app-wide change touches neither a session row nor a + turn-reported pin, and a chat's chip goes on naming its own binding. The cross-window suite + pins this. +- **Lead/Worker's `(lead)` / `(worker)` suffix** is read by `ModelsBottomBar` when it mounts. + The model name beside it is live; the suffix in a second window is not. +- **An unannounced write while the window keeps focus** leaves the chip stale until the next + focus change or the next new-chat send, which re-reads before it creates anything. An + existing chat is unaffected either way: it runs on its own row. +- **The window between the last look and `/agent/start`** — two loopback round trips — is not + closed. Closing it needs `/agent/start` to accept an expected binding and refuse a + mismatch, which is daemon work. + +## Tests + +| Suite | What it pins | +|---|---| +| `components/ModelAndProviderContext.crossWindow.test.tsx` | two providers, each rendering the real chip over one fake daemon: a second window's chip — model, provider and privacy — follows the nudge without a remount; it equals what the next `/agent/start` would bind after a switch in either direction; ordering (a slow older read, a failed newer read); focus and visibility re-reads; the send refusal; a per-chat switch leaving new chats alone; the pin outranking a stale row | +| `utils/sessionBindingSync.test.ts` | the nudge is synchronous locally, carries no values, crosses windows, and never mixes with a binding | +| `components/ConfigContext.test.tsx` | `upsert` / `remove` of the two keys announce once the write resolved; other keys and refused writes do not | +| `components/privacy/useConfirmNewChatModel.test.tsx` | when the last look refuses and when it must not; both composers call it before `createSession`, pinned at the source | +| `settings/models/subcomponents/SwitchModelModal.test.tsx` | the dialog's scope copy and the unticked box | +| `settings/models/bottom_bar/ModelsBottomBar.pinned.test.tsx` | the "Model for new chats" heading where there is no chat | + +```bash +cd ui/desktop && npx vitest run src/components/ModelAndProviderContext* src/utils/sessionBindingSync* +``` + +## Checking it in the running app + +Launch a sandboxed instance with the dev GUI launcher on a free CDP port, open a second window +with `window.electron.createChatWindow()` from the first window's DevTools, and read each +window's chip by its accessible name (`button[aria-label^="Current model:"]`). + +1. Change the model from window 1's Home chip (or tick **Also use for new chats** in a chat). + Window 2's chip must read the new model within two seconds. +2. Send from window 2's Home composer, then read what the turn really ran on: + + ```bash + sqlite3 "$SANDBOX/sessions/sessions.db" "select provider, model_id from token_events order by id desc limit 1" + ``` + +3. Repeat in the private → public direction. At no point may window 2's chip read "Private + model, UCSF" while its next turn goes to a public model. + +## Related documentation + +- [Privacy tiers](../security/privacy-tiers.md) — §14.3 P4 is the decoupling this ships; the ledger at the top says what else shipped. +- [Renderer testing traps](renderer-testing-traps.md) — why the ticket compares against the last applied read. +- [The provider catalog](provider-catalog.md) — the other surface that opens the model switcher, including onboarding. +- [Launching the dev GUI from a shell without a TTY](launching-the-dev-gui.md) — how to put two windows in front of you for the runtime check. diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 8153afa87..2f2fbfc04 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -112,6 +112,13 @@ this section is the ledger. higher price for a yes, never the absence of one** — a build that withheld the control there would restore the hard block DR-26 exists to prevent, for exactly the deployments careful enough to choose `strict`. +- **§14.3 P4's decoupling — added 2026-09-11, after this ledger was written.** A model switch made + in a chat changes that chat only; making it the model new chats start on is an explicit, + unticked "Also use for new chats" box in the switcher. Provider QA F measured the coupling it + replaces: one chat switched to Claude Code for one check, and the next chat opened came up + public. The same change (QA finding F3) makes every window's chip follow the app-wide + selection live, so no window names a private model while its next new chat would bind a public + one. See [model selection across windows](../desktop-ui/model-selection-across-windows.md). ### Did not ship From 6b6f508cd184375e48f5567235c7ef7b73ac73ec Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 12:11:44 -0700 Subject: [PATCH 4/5] desktop: "Also use for new chats" ticks when its square is clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by driving the running app. `Checkbox` draws its square beside an `sr-only` input, and the switcher's label was a SIBLING (`htmlFor`), so only the words toggled the box — a click on the square itself did nothing. Wrap the box and its words in one `label`, as SessionListView's checkbox already is. The new test clicks the square (Checkbox's own target) and fails against the sibling-label markup with `expect(element).toBeChecked()`. --- .../subcomponents/SwitchModelModal.test.tsx | 15 +++++++++++++++ .../models/subcomponents/SwitchModelModal.tsx | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx index 58b25cd51..cc20e7350 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.test.tsx @@ -299,6 +299,21 @@ describe('SwitchModelModal — what the switch changes', () => { await settle(); }); + /** + * ⚠ Found by driving the running app, not by a test. `Checkbox` draws its + * square beside an `sr-only` input; with the label as a SIBLING (`htmlFor`) + * only the words toggled it, and a click on the square — the thing a person + * aims at — did nothing at all. + */ + it('ticks when the square itself is clicked, not only its words', async () => { + renderModal('s-1'); + const box = screen.getByRole('checkbox', { name: new RegExp(ALSO_FOR_NEW_CHATS_LABEL) }); + // The square: Checkbox's own 24px target, which holds the hidden input. + fireEvent.click(box.parentElement as HTMLElement); + expect(box).toBeChecked(); + await settle(); + }); + it('leaves new chats alone unless the box is ticked', async () => { renderModal('s-1'); fireEvent.click(await confirm()); diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 7cc37e1bc..850d2ade0 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -942,9 +942,17 @@ export const SwitchModelModal = ({ default: a switch made in a chat is a statement about that chat, and a public model chosen for one scratch chat must not become what every new chat — in every window — silently starts on. + + ⚠ The `label` WRAPS the box, as `SessionListView`'s does. `Checkbox` + draws a square beside an `sr-only` input, so with a sibling label + (`htmlFor`) only the text toggled it and clicking the square itself did + nothing — found in the running app, which is where it showed. */} {sessionId && !hostManaged && ( -
+
+ + )} {submitError && ( From 21abab80c0a8c90d6e81849b5ca918b360b6d51d Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 12:11:45 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20model=20selection=20across=20window?= =?UTF-8?q?s=20=E2=80=94=20measured=20runtime=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the sandbox store path (`/data/sessions/sessions.db`), add the recipe for seeing the last look refuse an unannounced hand edit, record the toast-class trap (`TOAST_SURFACE_CLASS_NAME`, not `Toastify__toast`), and the 2026-09-11 measurements: 310 ms and 390 ms cross-window lag, both turns' token_events equal to the chip at send, the last look refusing both directions within 100 ms with no focus event. Also records, under "What this does not cover", that `/config/set_provider` writes provider then model as two writes — `config.yaml` held a mixed pair for ~55 ms — which a `/agent/start` in the gap would bind. Daemon work. --- .../model-selection-across-windows.md | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/desktop-ui/model-selection-across-windows.md b/docs/desktop-ui/model-selection-across-windows.md index 64170201e..99401e27a 100644 --- a/docs/desktop-ui/model-selection-across-windows.md +++ b/docs/desktop-ui/model-selection-across-windows.md @@ -106,6 +106,13 @@ the daemon classifies the chat by what it binds, whatever this check does. - **The window between the last look and `/agent/start`** — two loopback round trips — is not closed. Closing it needs `/agent/start` to accept an expected binding and refuse a mismatch, which is daemon work. +- **`/config/set_provider` is not atomic.** `set_config_provider` writes + `BIOROUTER_PROVIDER` and then `BIOROUTER_MODEL` as two config writes; measured on + 2026-09-11, `config.yaml` held `versa_azure` beside `gpt-6-astra` for about 55 ms of a + Codex → Versa switch. A re-read caused by an announcement never sees it, because the + announcement follows the write; one caused by a focus change landing in the gap could, and + the announcement right behind it corrects the chip. A `/agent/start` landing in the gap has + no such second chance and would bind the mixed pair. Daemon work. ## Tests @@ -130,14 +137,28 @@ window's chip by its accessible name (`button[aria-label^="Current model:"]`). 1. Change the model from window 1's Home chip (or tick **Also use for new chats** in a chat). Window 2's chip must read the new model within two seconds. -2. Send from window 2's Home composer, then read what the turn really ran on: +2. Send from window 2's Home composer, then read what the turn really ran on. The store sits + under the sandbox's `data/`, not beside `config/`: ```bash - sqlite3 "$SANDBOX/sessions/sessions.db" "select provider, model_id from token_events order by id desc limit 1" + sqlite3 -readonly ~/biorouter-runs//data/sessions/sessions.db "select provider, model_id from token_events order by id desc limit 1" ``` 3. Repeat in the private → public direction. At no point may window 2's chip read "Private model, UCSF" while its next turn goes to a public model. +4. To see the last look refuse, hand-edit the two keys in `~/biorouter-runs//config/config.yaml` + and send from window 2 without giving it focus. The chip changes, the text stays in the + composer, and no session is created. ⚠ The toast carries the app's own class + (`TOAST_SURFACE_CLASS_NAME`), not `Toastify__toast`: watch `section.Toastify`, or an + observer will report "no toast" for one that rendered. + +Measured on 2026-09-11 in a sandboxed instance at load average ~120, with both chips logged +every 50 ms against one clock: window 2's chip followed window 1's switch in 310 ms +(Versa → Codex) and 390 ms (Codex → Versa), in both cases directly from one correct label to +the other. The two turns sent from window 2 recorded `codex / gpt-6-astra` and +`versa_azure / gpt-5.5-2026-04-24` in `token_events`, each equal to the chip at the moment of +sending. The last look refused both directions of an unannounced hand edit within 100 ms, +with no focus or visibility event involved. ## Related documentation