Skip to content

Desktop: every window's model chip follows the app-wide selection, and a chat's switch stays in the chat (F3) - #247

Open
Broccolito wants to merge 8 commits into
mainfrom
fix/f3-app-model-cross-window
Open

Desktop: every window's model chip follows the app-wide selection, and a chat's switch stays in the chat (F3)#247
Broccolito wants to merge 8 commits into
mainfrom
fix/f3-app-model-cross-window

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes finding F3 (HIGH) of the 2026-09-10 provider QA run on merged main 7c96d796 (test-drive/qa-e/report.md §F3), corroborated by QA F's housekeeping note and QA B's §2 chip rows / F2. Renderer only.

The measurement

QA E, both directions: change the app-wide model in window 1 and window 2's chip never moved (8 s).

  • Window 2 chip claude-fable-5-1 (Public model), global set to Codex → send from window 2 → token_events codex / gpt-6-astra.
  • Window 2 chip gpt-5.5-2026-04-24 (Private model, UCSF) at the instant of send, global set to Claude Code → session provider_name=claude_code, privacy_tier=public, token_events claude_code / claude-fable-5-1.

The privacy machinery held (the chat was classified public). 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 two keys say on the daemon at that instant (configured_new_session_provider — it takes no provider from the renderer). utils/sessionBindingSync's header even claimed each window "picks it up from its own config read"; it read it once.

What changed

Every window follows the app-wide selection

  • Every renderer write of the two keys announces on the existing biorouter:session-binding channel: changeModel, the first-run seeding in getFallbackModelAndProvider, and ConfigContext.upsert / remove. The last covers the writers that never go through changeModel — the local and coding-agent onboarding cards, Lead/Worker settings, Settings' reset — which before this did not update even their own window's chip.
  • The announcement is a nudge, never a payload ({ kind: 'app-model-selection' }, no provider, no model). Two windows' writes can be announced in the opposite order from the one they landed in; a receiver that applied values ends on the last message, one that re-reads ends on the last write — the one /agent/start binds. Same reason catalogSubscription refetches.
  • ModelAndProviderContext re-reads on the nudge and when its window regains focus or becomes visible (for writes nothing announces: biorouter configure, a hand edit — the daemon's config cache is keyed on the file's stamp). The subscription is mount-once, read through a ref (subscriptions belong to mounts, not lookups). The re-read is a pure read (syncAppModelSelection): it never seeds the bundled default, so neither focus nor another window can write config.
  • Every statement of the selection is ticketed — mount read, re-read, own switch — and publishes only if nothing issued after it has already been published, compared against the last applied ticket (renderer-testing-traps.md, "Newest issued is the wrong rule"). A read that returns no body keeps the label instead of erasing it.
  • The chip, gauge, cost line and privacy padlock all derive from that context state, so they move together. The pin still outranks a stale row: the app-wide selection touches neither.

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; on a known mismatch it puts the fresh model on screen, raises Message not sent naming the new model, provider and tier in words, and resolves false so ChatInput gives the text back. This closes the one gap no ear hears in time — a biorouter configure in the terminal docked inside the window never takes its focus. It refuses only a known mismatch (nothing named yet, or a failed read, proceed as before), and it is not a gate: the daemon classifies by what it binds regardless.

Decision: a switch made in a chat changes that chat (privacy-tiers §14.3 P4)

The brief asked to decide whether the composer chip should change the app-wide default at all. Decided: no, not by default. docs/security/privacy-tiers.md §14.3 P4 already asked for exactly this — "pick Versa once in a scratch chat privatises not one session but every session created afterwards… Decouple: offer 'Also make this my default for new chats' as an explicit checkbox" — and QA F measured the mirror image (Claude Code bound for one check, the next chat came up public).

Switcher opened from Changes Dialog says
a chat that chat only (/agent/update_provider) "Select a provider and model for this chat." + unticked Also use for new chats
a chat, box ticked the chat and the app-wide selection (announced to every window) "New chats in every window will start on this model."
Home / a chat not yet started / Settings → Models / onboarding the app-wide selection — the only thing there is "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 where there is no chat the chip's dropdown is headed Model for new chats ("New chats in every window start on this model. Existing chats keep their own.").

Merged with main (2026-09-11)

origin/main moved 60 commits (19 QA-fix PRs); merged in 85af195e. One textual conflict — ConfigContext.tsx's imports, where H3 (#227) widened the privacy-tiers import beside this branch's sessionBindingSync import; both kept. Checked by hand: SwitchModelModal.tsx (#222's derived validation + aria-describedby with this branch's scope copy, box and changeModel options), its privacy test (#222's new tests count calls; the one exact-argument assertion is this branch's), Hub.tsx / BaseChat.tsx (#227's PrivacyTiersOffNote beside the pre-send check) and privacy-tiers.md. main added no new writer of BIOROUTER_PROVIDER/BIOROUTER_MODEL, so every writer still announces. Re-verified on the merged tree: typecheck, the targeted suites (4 files, 46 passed), npm run lint:check (exit 0), Prettier on the 16 changed frontend files.

Tests

Gate Result
cd ui/desktop && npx vitest run src/components/ModelAndProviderContext* src/utils/sessionBindingSync* 4 files, 46 passed
npm run test:run before the merge: 444 files, 5011 passed, 1 skipped, exit 0. After merging main: 5049 passed, 1 skipped (5050), 446 of 447 files; the one file is artifactCdnAssets.browser.test.ts, whose afterAll browser.close() overran 30 s at load ~50–400 after both its tests passed — none of this branch's files are in its import graph, and on a pristine origin/main worktree at load 431 the same file took 18.5 s
npm run lint:check exit 0 — tsc, ESLint --max-warnings 0, themes, 332 contrast assertions, token mirrors
npx prettier --check on all 16 changed frontend files All matched files use Prettier code style!

New suites: ModelAndProviderContext.crossWindow.test.tsx (13 — two ModelAndProviderProvider trees, each rendering the real composer chip, over one fake daemon whose two keys are what /agent/start would bind), privacy/useConfirmNewChatModel.test.tsx (10, incl. a source guard that both composers look before createSession), plus additions to sessionBindingSync.test.ts (+6), ConfigContext.test.tsx (+5), SwitchModelModal.test.tsx (+5), ModelsBottomBar.pinned.test.tsx (+2), ModelAndProviderContext.test.tsx (+1). Two existing assertions changed deliberately: the "bind, announce, global" ordering test now opts in with alsoForNewChats: true, and the pre-flight test's changeModel call carries { alsoForNewChats: false }.

Fail-before

Implementation files swapped back to origin/main (tests at HEAD), run, restored by an EXIT trap with absolute paths (checksums matched, empty diff): 30 of the new tests fail, 7 files. The two the brief asked for, verbatim:

FAIL  src/components/ModelAndProviderContext.crossWindow.test.tsx > F3 — the app-wide selection reaches every window > updates a second window's chip — model, provider and privacy — without a remount
Error: expect(element).toHaveAccessibleName()
Expected element to have accessible name:
  Current model: claude-fable-5-1 (Public model)
Received:
  Current model: gpt-5.5-2026-04-24 (Private model, UCSF)

FAIL  src/components/ModelAndProviderContext.crossWindow.test.tsx > F3 — the app-wide selection reaches every window > states what the next new chat would bind, after a switch in either direction
Expected element to have accessible name:
  Current model: claude-fable-5-1 (Public model)
Received:
  Current model: gpt-5.5-2026-04-24 (Private model, UCSF)

In the running app

Own sandboxed instance (launch-dev-gui.sh, CDP 9383), second window via window.electron.createChatWindow(), both chips' accessible names logged every 50 ms against one clock, config.yaml polled from disk.

Step Result
W1 Home chip → Codex / gpt-6-astra W2 chip gpt-5.5-2026-04-24 (Private model, UCSF)gpt-6-astra (Public model) 310 ms after the click, no intermediate label
send from W2 select provider, model_id from token_events order by id desc limit 1codex|gpt-6-astra; session codex / gpt-6-astra / public
W1 Home chip → Versa API Azure / gpt-5.5-2026-04-24 W2 chip → gpt-5.5-2026-04-24 (Private model, UCSF) 390 ms after the click
send from W2 versa_azure|gpt-5.5-2026-04-24; session versa_azure / … / private
W1 switches an existing chat, box unticked chat row → claude_code; config.yaml untouched; W2 Home chip unchanged
W1 switches the chat, box ticked config.yamlcodex +70 ms; W2 Home chip follows +257 ms
unannounced hand edit of config.yaml, send from W2 with no focus change refused in both directions within 100 ms: chip corrected, "Message not sent — New chats now start on gpt-5.5-2026-04-24 (Versa API Azure, a private model), not gpt-6-astra…", text back in the composer, no session, no token_events row, focusLog empty

At no point did W2's chip read "Private model, UCSF" when its next turn went public. The runtime pass also found a real defect that no test had caught — "Also use for new chats" did not tick when its square was clicked (Checkbox's input is sr-only and the label was a sibling), fixed in 6b6f508c with a regression test that fails against the old markup.

Turns used versa_azure and, for the public direction the check requires, one short codex turn — the operator's standing testing rule names both; no local model was touched.

Not covered here

Design record: docs/desktop-ui/model-selection-across-windows.md; privacy-tiers.md's "What shipped" ledger gains a dated P4 line.

Not merged — per the brief.

🤖 Generated with Claude Code

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.
…(F3)

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.
… ledger

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.
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()`.
Correct the sandbox store path (`<run>/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.
main moved 60 commits (19 QA-fix PRs). One textual conflict, in
ConfigContext.tsx's imports: main widened the privacy-tiers import for H3's
master-switch record (`PRIVACY_TIERS_RECORD_KEY`, `privacyTiersRecordFromConfig`,
`PrivacyTiersRecord`); this branch added the `sessionBindingSync` import beside
it. Both kept.

Auto-merged and checked by hand: SwitchModelModal.tsx (#222's derived
`validation` + `aria-describedby` alongside this branch's scope copy, "Also use
for new chats" box and `changeModel` options), its privacy test (#222's three
new tests use call counts; the one exact-argument assertion is this branch's),
Hub.tsx and BaseChat.tsx (#227's PrivacyTiersOffNote mount beside this
branch's pre-send check), and privacy-tiers.md (both ledger additions). The
renderer's writers of BIOROUTER_PROVIDER/BIOROUTER_MODEL are unchanged by main,
so every one still announces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant