Skip to content

fix(desktop): refuse a barred model before the click, and say a confirmed Stop stopped (F3, F5) - #222

Merged
Broccolito merged 3 commits into
mainfrom
fix/model-preflight-and-confirmed-stop
Sep 11, 2026
Merged

fix(desktop): refuse a barred model before the click, and say a confirmed Stop stopped (F3, F5)#222
Broccolito merged 3 commits into
mainfrom
fix/model-preflight-and-confirmed-stop

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

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).

  • F3 — the Switch-models dialog's primary button stays enabled on a barred selection. Private chat → model chip → Change Model → provider Claude Code: every model row is aria-disabled with "Unavailable: this is a private chat, so only private models may run in it", yet Select model was disabled: false with 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.
  • F5 — a successful Stop states no outcome at all. The failed-Stop notice (fix(chat): a failed Stop leaves a usable composer and says so (M2) #214) covers only the failure path. A Stop that worked brought Send back in ~150 ms and left the user's message with no reply and no word about why.

F3 — what was wrong, what changed

SwitchModelModal.tsx: isValid started true (useState(true)) and validateForm ran only once attemptedSubmit was 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 asking isOptionDisabled (findFirstAvailableModel), and initialModel, 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.

  • Validity is now derived from the selection on every render (validation, a useMemo) rather than computed in the click. A barred selection disables Select model and shows its reason beside the field at once; that reason is the button's aria-describedby.
  • The "nothing chosen yet" prompts (Select a provider, Select or type a model name) still wait for an attempt — they are prompts, not refusals.
  • handleSubmit reads the same verdict, as the post-click fallback.
  • The two doc comments that stated the rule (PUBLIC_MODEL_IN_PRIVATE_CHAT's and the privacyTier prop's) now describe it truthfully, including why the confirm needs it as well as the rows.
  • 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.

F5 — what was wrong, what changed

On a confirmed cancel, settleStoppedTurn bumps 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's cancelled means "a running turn was found and its cancellation token tripped", while cancelled: false is its idempotent 200 for a turn that had already ended (routes/reply.rs: idle_cancel_response, and the Stop-and-Send "already retired" arm).

  • requestExactTurnSettlement records cancelled on a settled answer (lastStopCancelled, beside lastStopFailure); settleStoppedTurn sets stopConfirmed on the snapshot only for cancelled: true.
  • BaseChat renders it as ChatTurnStopped in the slot a failed Stop's card takes — after the pending tool calls, beside ChatTurnError: one line, "Stopped.", text-supporting in --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.
  • Transient, never persisted: retracted after 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 only session and messages, so a reload never resurrects it.
  • Not shown for a turn that ended on its own, a Stop that raced the turn to its end (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.
  • A confirmed stop also retracts M2's interim "Turn stopped" card (raised when a wedged writer's synthesized ending lands while the cancel is still out), since the two would say one thing twice.

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:

 FAIL  src/components/conversation/ChatTurnStopped.test.tsx [ src/components/conversation/ChatTurnStopped.test.tsx ]
Error: Failed to resolve import "./ChatTurnStopped" from "src/components/conversation/ChatTurnStopped.test.tsx". Does the file exist?
 FAIL  src/hooks/chatStreamStore.test.ts > ChatStreamRegistry — a Stop the daemon confirms (F5) > says the turn was stopped once the daemon confirms it
AssertionError: expected undefined to be defined
 FAIL  src/hooks/chatStreamStore.test.ts > ChatStreamRegistry — a Stop the daemon confirms (F5) > says so when the cancelled turn’s own ending arrives before the confirmation
AssertionError: expected undefined to be defined
 FAIL  src/hooks/chatStreamStore.test.ts > ChatStreamRegistry — a Stop the daemon confirms (F5) > replaces the interim “Turn stopped” card once the cancel confirms
AssertionError: expected { …(6) } to be undefined
 FAIL  src/hooks/chatStreamStore.test.ts > ChatStreamRegistry — a Stop the daemon confirms (F5) > is transient — it retracts itself after its display window
AssertionError: expected undefined to be defined
 FAIL  src/hooks/chatStreamStore.test.ts > ChatStreamRegistry — a Stop the daemon confirms (F5) > is retracted the moment the next turn starts
AssertionError: expected undefined to be defined
 FAIL  src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx > SwitchModelModal — pre-flight, not post-refusal > disables the confirm, with the reason beside it, before any click on a barred selection
Error: expect(element).toBeDisabled()
Received element is not disabled:
 FAIL  src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx > SwitchModelModal — pre-flight, not post-refusal > re-validates when the selection moves off the barred provider
Error: expect(element).toBeDisabled()
Received element is not disabled:
 Test Files  3 failed (3)
      Tests  7 failed | 104 passed (111)

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":

  • F3: leaves the confirm live for a private model in a private chat.
  • F5: does not appear for:
    • a turn that ended on its own;
    • a turn that had already finished before the cancel reached it;
    • a Stop that was never confirmed;
    • a Stop-and-Send;
    • (added with the fix) an ordinary Stop upgraded to Stop-and-Send mid-flight.

Mutation checks, both reverted:

  • Dropping !this.stopContinuationPending from the notice condition fails the mid-flight-upgrade guard (expected { Object (turnId) } to be undefined).
  • Forcing lastStopCancelled = true fails does not appear when the turn had already finished before the cancel reached it.

Pass-after: the briefed verification

$ cd ui/desktop && npx vitest run src/components/settings/models src/components/conversation src/hooks/chatStreamStore.*
 Test Files  25 passed (25)
      Tests  309 passed (309)
exit=0
$ npm run test:run            # run 1
 Test Files  443 passed (443)
      Tests  4985 passed | 1 skipped (4986)
     Errors  1 error
exit=1

$ npm run test:run            # run 2, unchanged tree
 Test Files  443 passed (443)
      Tests  4985 passed | 1 skipped (4986)
exit=0

The one error in run 1 is not this change:

Unhandled Rejection
ReferenceError: window is not defined
 ❯ checkInitial src/components/onboarding/OllamaInlineCard.tsx:41:9
This error originated in "src/components/settings/providers/ProviderCatalog.test.tsx" test file.
  • OllamaInlineCard's mount effect sets state with no unmount guard.
  • ProviderCatalog.test.tsx does not mock the Ollama probes, so they make real fetches to localhost:11434.
  • On this machine a local Ollama answers (curl …/api/tags → 200), so the second probe can resolve after jsdom teardown.
  • The file passes alone 3/3, and the tree it failed on passed on rerun.
  • Pristine origin/main passed its control run (442 passed / 4969 passed | 1 skipped, exit 0). The +1 file / +16 tests are this branch's.
  • Flagged as a separate task rather than fixed here.
$ npm run lint:check
OK — generated artifacts are current (3 themes)
OK — all 332 contrast assertions pass
OK — every semantic colour token used by a utility has a @theme inline mirror
exit=0

(tsc --noEmit and ESLint --max-warnings 0 printed nothing.)

$ npx prettier --check <the 8 changed files>
Checking formatting...
All matched files use Prettier code style!

Runtime: own sandboxed instance

Instance: ~/biorouter-runs/launch-dev-gui.sh, run f3f5-verify, CDP 9377, light Parchment. Real turns on versa_azure / gpt-5.5-2026-04-24.

F3. Private chat 20260911_1 → chip → Change Model → provider Claude Code, before any click:

  • The fields hold Claude Code / claude-fable-5-1 (the auto-select put the barred model there).
  • Select model has disabled: true.
  • aria-describedby"Unavailable: this is a private chat, so only private models may run in it".
  • No submit error.
  • Control: on its own private provider (Versa API Azure / gpt-5.5) the same dialog's confirm is live.

F5. Stopped mid-stream in 20260911_2 (663 characters streamed):

Measured Value
/agent/cancel 200 {"cancelled":true,"turn_id":"turn-1","settled":true}, +143 ms after the click
"Stopped." shown +179 ms, in the same frame as Stop → Send
Retracted 4989 ms later
Computed style 12px / 16px / 400; color rgb(99, 92, 84) = --text-muted; role="status"; 24 px line box; margin-top: 16px; fade-slide-up — so every class generated under BIOROUTER_NO_HMR
Second stop {"cancelled":true,"turn_id":"turn-2","settled":true}, same line
A turn that ended on its own ("ready", 8 s) no line, and no cancel sent

The 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:

  • M2's interim "Turn stopped" card appeared at +2.15 s, the daemon's 2 s drain grace.
  • At +30.16 s the bare 504 arrived: "Stop not confirmed … This chat is usable again", and Send came back.
  • No "Stopped." line, as designed.

The confirmed runs used CONTEXT_WORKSPACE_SUMMARY: false in that sandbox only; it was reverted afterwards.

Screenshots (local, ~/biorouter-runs/f3f5-verify/shots/):

  • F3-barred-selection-before-click.png
  • F3-dialog-open-private-provider.png
  • F5-stopped-notice-confirmed.png
  • F5-after-5s-notice-retracted.png
  • F5-negative-unconfirmed-stop-no-notice.png
  • F5-wedged-daemon-interim-card.png

Files changed

File
ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx derived validation; pre-click refusal wired as aria-describedby; doc comments
ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.privacy.test.tsx 3 tests (2 fail-before, 1 control), real react-select
ui/desktop/src/hooks/chatStreamStore.tsx stopConfirmed, lastStopCancelled, STOP_CONFIRMED_NOTICE_MS, the retraction sites
ui/desktop/src/hooks/useChatStream.ts exposes stopConfirmed
ui/desktop/src/components/conversation/ChatTurnStopped.tsx the line
ui/desktop/src/components/BaseChat.tsx renders it in the failed-Stop notice's slot
ui/desktop/src/hooks/chatStreamStore.test.ts 10 tests (5 fail-before, 5 guards); endedWithoutTerminal hoisted so M2's and F5's batteries share it
ui/desktop/src/components/conversation/ChatTurnStopped.test.tsx 3 tests, including the BaseChat wiring asserted at the source (BaseChat cannot mount in jsdom)

Deliberately not here

🤖 Generated with Claude Code

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
Broccolito merged commit 3aa6866 into main Sep 11, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/model-preflight-and-confirmed-stop branch September 11, 2026 20:48
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.
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