fix(desktop): refuse a barred model before the click, and say a confirmed Stop stopped (F3, F5) - #222
Merged
Merged
Conversation
F3 — the Switch-models dialog must disable "Select model", with the reason beside the field, before any click when the selection itself is barred (a public model auto-selected in a private chat). Fails today: validity starts `true` and is first computed inside the click. F5 — a Stop the daemon confirms (`cancelled: true, settled: true`) must say so in the transcript, transiently, and must NOT for a turn that ended on its own, a Stop that found nothing running, an unconfirmed Stop, or a Stop-and-Send. The five positive cases fail today; the four negative guards pass before and after by design. Tests only; the fixes follow.
…e click (F3) A disabled row is not a disabled selection. `isOptionDisabled` keeps a barred model from being picked, but the auto-select effect fills the field with the provider's first model without asking it, so choosing Claude Code in a private chat put a model whose every row was disabled into the field — and "Select model" stayed live, because `isValid` started `true` and `validateForm` only ran once `attemptedSubmit` was set. The click was refused (the gate held); the pre-flight the file's own doc promised did not exist for the primary action. Validity is now derived from the selection on every render instead of being computed inside the click. A barred selection disables the confirm and shows its reason beside the field at once, wired to the button as its `aria-describedby`; the "nothing chosen yet" prompts still wait for an attempt, and `handleSubmit` reads the same verdict as the post-click fallback. The two doc comments that claimed the rule now describe it truthfully. One visible consequence beyond the barred case: the predefined-model list's confirm is now disabled until a model is chosen, matching the provider/model branch, instead of refusing after a click.
#214 gave a failed Stop its in-chat notice; a successful one still stated no outcome. Send came back in 150 ms and the transcript held the user's message with no reply and no word about why — the settle path bumps the stream id and aborts the socket, so the daemon's closing frame is never read and nothing else spoke. A settled cancel now records the daemon's own `cancelled` answer, and only `cancelled: true` (the cancel found the turn running and tripped it) sets `stopConfirmed` on the snapshot. BaseChat renders it as `ChatTurnStopped` in the slot a failed Stop's card takes: one muted `text-supporting` line, "Stopped.", with the Stop button's glyph, in a polite live region, at the trailing activity line's height so it lands where "working" was. It is transient — retracted after STOP_CONFIRMED_NOTICE_MS (design.md §4.3's 5 s) by an identity-checked timer, and at once by a submit, an attach, an observed running turn or a fresh load — and never persisted. It does not appear for a turn that ended on its own, for a Stop that raced the turn to its end (`cancelled: false`), for an unconfirmed Stop (M2's card speaks instead), or for a Stop-and-Send, including an ordinary Stop upgraded to one mid-flight, whose replacement turn is the outcome. A confirmed stop also retracts M2's interim "Turn stopped" card, which a wedged writer's synthesized ending raises while the cancel is out, since the two would say one thing twice.
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
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.
The findings
Two LOW findings from the 2026-09-10 QA drive of merged main
7c96d796(~/biorouter-runs/test-drive/qa-a/report.md, sections F3 and F5).aria-disabledwith "Unavailable: this is a private chat, so only private models may run in it", yetSelect modelwasdisabled: falsewith no reason on screen. Clicking it produced the inline refusal and did not switch — the gate held; the pre-flight the file's own doc promised did not exist for the primary action.F3 — what was wrong, what changed
SwitchModelModal.tsx:isValidstartedtrue(useState(true)) andvalidateFormran only onceattemptedSubmitwas set, so validity was first computed inside the click. The disabled rows did not help, because a disabled row is not a disabled selection: the auto-select effect fills the field with the provider's first model without askingisOptionDisabled(findFirstAvailableModel), andinitialModel, the carried-over current model, the custom-model field and the predefined list all put a model in the field without the menu's consent.validation, auseMemo) rather than computed in the click. A barred selection disablesSelect modeland shows its reason beside the field at once; that reason is the button'saria-describedby.handleSubmitreads the same verdict, as the post-click fallback.PUBLIC_MODEL_IN_PRIVATE_CHAT's and theprivacyTierprop's) now describe it truthfully, including why the confirm needs it as well as the rows.F5 — what was wrong, what changed
On a confirmed cancel,
settleStoppedTurnbumps the stream id and aborts the socket, so the daemon's own closing frame is never read, and nothing else spoke. The fix is keyed on the daemon's answer, not on the press:/agent/cancel'scancelledmeans "a running turn was found and its cancellation token tripped", whilecancelled: falseis its idempotent 200 for a turn that had already ended (routes/reply.rs:idle_cancel_response, and the Stop-and-Send "already retired" arm).requestExactTurnSettlementrecordscancelledon a settled answer (lastStopCancelled, besidelastStopFailure);settleStoppedTurnsetsstopConfirmedon the snapshot only forcancelled: true.BaseChatrenders it asChatTurnStoppedin the slot a failed Stop's card takes — after the pending tool calls, besideChatTurnError: one line, "Stopped.",text-supportingin--text-muted(the settings vocabulary's status-line role), the Stop button's own glyph,role="status". It takes the trailing activity line's geometry (24 px,mt-4), so it lands where "working" was.STOP_CONFIRMED_NOTICE_MS(5 s, design.md §4.3's toast duration) by an identity-checked timer, and at once by a submit, an attach, an observed running turn or a fresh load. The LRU cache holds onlysessionandmessages, so a reload never resurrects it.cancelled: false), an unconfirmed Stop (M2's card speaks instead), or a Stop-and-Send — including an ordinary Stop upgraded to one mid-flight — whose replacement turn is the outcome.No Rust changed.
Evidence
Fail-before, on
34ac8acc(the tests-only commit)npx vitest run src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx src/hooks/chatStreamStore.test.ts src/components/conversation/ChatTurnStopped.test.tsx, FAIL and assertion lines:Some tests pass both before and after by design. They are the guards that keep the fix from being "disable the confirm in every private chat" or "announce every Idle":
Mutation checks, both reverted:
!this.stopContinuationPendingfrom the notice condition fails the mid-flight-upgrade guard (expected { Object (turnId) } to be undefined).lastStopCancelled = truefails does not appear when the turn had already finished before the cancel reached it.Pass-after: the briefed verification
The one error in run 1 is not this change:
OllamaInlineCard's mount effect sets state with no unmount guard.ProviderCatalog.test.tsxdoes not mock the Ollama probes, so they make realfetches tolocalhost:11434.curl …/api/tags→ 200), so the second probe can resolve after jsdom teardown.origin/mainpassed its control run (442 passed/4969 passed | 1 skipped, exit 0). The +1 file / +16 tests are this branch's.(
tsc --noEmitand ESLint--max-warnings 0printed nothing.)Runtime: own sandboxed instance
Instance:
~/biorouter-runs/launch-dev-gui.sh, runf3f5-verify, CDP 9377, light Parchment. Real turns onversa_azure / gpt-5.5-2026-04-24.F3. Private chat
20260911_1→ chip → Change Model → provider Claude Code, before any click:Claude Code/claude-fable-5-1(the auto-select put the barred model there).Select modelhasdisabled: true.aria-describedby→ "Unavailable: this is a private chat, so only private models may run in it".F5. Stopped mid-stream in
20260911_2(663 characters streamed):/agent/cancel200 {"cancelled":true,"turn_id":"turn-1","settled":true}, +143 ms after the clickrgb(99, 92, 84)=--text-muted;role="status"; 24 px line box;margin-top: 16px;fade-slide-up— so every class generated underBIOROUTER_NO_HMR{"cancelled":true,"turn_id":"turn-2","settled":true}, same lineThe unconfirmed path, measured by accident. The staged dev daemon (built 2026-09-09 10:53) predates the workspace-summary move off the turn's critical path (#218);
strings | grep -c CONTEXT_WORKSPACE_SUMMARY_BUDGET_MS→ 0. So the first chat, in$HOME, wedged in the directory walk. What happened after Stop:The confirmed runs used
CONTEXT_WORKSPACE_SUMMARY: falsein that sandbox only; it was reverted afterwards.Screenshots (local,
~/biorouter-runs/f3f5-verify/shots/):F3-barred-selection-before-click.pngF3-dialog-open-private-provider.pngF5-stopped-notice-confirmed.pngF5-after-5s-notice-retracted.pngF5-negative-unconfirmed-stop-no-notice.pngF5-wedged-daemon-interim-card.pngFiles changed
ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsxvalidation; pre-click refusal wired asaria-describedby; doc commentsui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsxui/desktop/src/hooks/chatStreamStore.tsxstopConfirmed,lastStopCancelled,STOP_CONFIRMED_NOTICE_MS, the retraction sitesui/desktop/src/hooks/useChatStream.tsstopConfirmedui/desktop/src/components/conversation/ChatTurnStopped.tsxui/desktop/src/components/BaseChat.tsxui/desktop/src/hooks/chatStreamStore.test.tsendedWithoutTerminalhoisted so M2's and F5's batteries share itui/desktop/src/components/conversation/ChatTurnStopped.test.tsxDeliberately not here
Finish { reason: "cancelled" }. It retracts M2's card, as the failed-Stop notice (fix(chat): a failed Stop leaves a usable composer and says so (M2) #214) designed, and says nothing, because the renderer does not readFinish.reason. Attributing that ending to the user's Stop without the cancel's confirmation is a separate decision.$HOMEchat on it cannot be stopped cleanly. Restage (just copy-binary debug) before runtime-testing Stop there.🤖 Generated with Claude Code