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
Open
Desktop: every window's model chip follows the app-wide selection, and a chat's switch stays in the chat (F3)#247Broccolito wants to merge 8 commits into
Broccolito wants to merge 8 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
claude-fable-5-1 (Public model), global set to Codex → send from window 2 →token_eventscodex / gpt-6-astra.gpt-5.5-2026-04-24 (Private model, UCSF)at the instant of send, global set to Claude Code → sessionprovider_name=claude_code,privacy_tier=public,token_eventsclaude_code / claude-fable-5-1.The privacy machinery held (the chat was classified
public). The label the human acted on did not.Root cause.
ModelAndProviderContextreadBIOROUTER_PROVIDER/BIOROUTER_MODELonce, on mount, while/agent/startbinds 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
biorouter:session-bindingchannel:changeModel, the first-run seeding ingetFallbackModelAndProvider, andConfigContext.upsert/remove. The last covers the writers that never go throughchangeModel— the local and coding-agent onboarding cards, Lead/Worker settings, Settings' reset — which before this did not update even their own window's chip.{ 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/startbinds. Same reasoncatalogSubscriptionrefetches.ModelAndProviderContextre-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.renderer-testing-traps.md, "Newest issued is the wrong rule"). A read that returns no body keeps the label instead of erasing it.The last look before a new chat
Both composers that create a chat — Home (
Hub.tsx) and a chat not yet started (BaseChat.tsx) — calluseConfirmNewChatModelimmediately beforecreateSession, 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 resolvesfalsesoChatInputgives the text back. This closes the one gap no ear hears in time — abiorouter configurein 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)./agent/update_provider)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/mainmoved 60 commits (19 QA-fix PRs); merged in85af195e. One textual conflict —ConfigContext.tsx's imports, where H3 (#227) widened the privacy-tiers import beside this branch'ssessionBindingSyncimport; both kept. Checked by hand:SwitchModelModal.tsx(#222's derivedvalidation+aria-describedbywith this branch's scope copy, box andchangeModeloptions), its privacy test (#222's new tests count calls; the one exact-argument assertion is this branch's),Hub.tsx/BaseChat.tsx(#227'sPrivacyTiersOffNotebeside the pre-send check) andprivacy-tiers.md.mainadded no new writer ofBIOROUTER_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
cd ui/desktop && npx vitest run src/components/ModelAndProviderContext* src/utils/sessionBindingSync*npm run test:runmain: 5049 passed, 1 skipped (5050), 446 of 447 files; the one file isartifactCdnAssets.browser.test.ts, whoseafterAllbrowser.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 pristineorigin/mainworktree at load 431 the same file took 18.5 snpm run lint:check--max-warnings 0, themes, 332 contrast assertions, token mirrorsnpx prettier --checkon all 16 changed frontend filesNew suites:
ModelAndProviderContext.crossWindow.test.tsx(13 — twoModelAndProviderProvidertrees, each rendering the real composer chip, over one fake daemon whose two keys are what/agent/startwould bind),privacy/useConfirmNewChatModel.test.tsx(10, incl. a source guard that both composers look beforecreateSession), plus additions tosessionBindingSync.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 withalsoForNewChats: true, and the pre-flight test'schangeModelcall 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:In the running app
Own sandboxed instance (
launch-dev-gui.sh, CDP 9383), second window viawindow.electron.createChatWindow(), both chips' accessible names logged every 50 ms against one clock,config.yamlpolled from disk.gpt-6-astragpt-5.5-2026-04-24 (Private model, UCSF)→gpt-6-astra (Public model)310 ms after the click, no intermediate labelselect provider, model_id from token_events order by id desc limit 1→codex|gpt-6-astra; sessioncodex / gpt-6-astra / publicgpt-5.5-2026-04-24gpt-5.5-2026-04-24 (Private model, UCSF)390 ms after the clickversa_azure|gpt-5.5-2026-04-24; sessionversa_azure / … / privateclaude_code;config.yamluntouched; W2 Home chip unchangedconfig.yaml→codex+70 ms; W2 Home chip follows +257 msconfig.yaml, send from W2 with no focus changetoken_eventsrow,focusLogemptyAt 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 issr-onlyand the label was a sibling), fixed in6b6f508cwith a regression test that fails against the old markup.Turns used
versa_azureand, for the public direction the check requires, one shortcodexturn — the operator's standing testing rule names both; no local model was touched.Not covered here
/config/set_provideris not atomic (daemon). It writesBIOROUTER_PROVIDER, thenBIOROUTER_MODEL; measured,config.yamlheldversa_azurebesidegpt-6-astrafor ~55 ms. No announcement-driven re-read sees it, but an/agent/startin that gap would bind the mixed pair./agent/start(two loopback round trips) needs/agent/startto accept an expected binding — daemon work.(lead)/(worker)suffix in a second window is still read at mount; the model name beside it is live.main; this PR's scope copy and box sit beside fix(desktop): refuse a barred model before the click, and say a confirmed Stop stopped (F3, F5) #222's derived validation in the same dialog.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