fix(direct-typing): say the mode stopped when the app comes back - #108
Conversation
Backgrounding the page disarms "Type into terminal" — the mirror has stopped tracking the pane, so the next keystroke would go into a terminal nobody is looking at. That part is right. Announcing it was not: clearMode() ran with no message at all, and a status published while the document is hidden would have expired unseen anyway (lib/status.ts drops a non-error after 2.5s). So you switch apps mid-command and come back to a focused field with the keyboard still up and nothing saying the mode is gone — and the characters you type next land in the REPLY DRAFT, a different action with an eventual Enter attached. Defer the message to the next `visible` event instead. The listener is now mounted for the hook's whole life rather than keyed on `active`: hiding the document is what disarms, so an effect keyed on the armed state tore its own listener down in the same commit and the return trip had nobody left to report it. Three things follow from the same reasoning: The disarm also puts the keyboard away. The notice expires; a focused field does not, and a primed field outlasting the only warning is precisely how terminal keystrokes become a buffered reply. That blur has to be deferred — focusInputEnd() is itself a timeout, so a synchronous blur is undone by a focus already queued — which means it can arrive after the field has changed hands. So it is owned: it carries the arm generation it was scheduled for and does nothing once a re-arm has taken over, is cancelled at a pane boundary and on unmount. The failed-batch path had the same deferred blur, unowned, and now shares the guarded one. The armed flag the listener reads is written at the transitions themselves (resetMode/activate) rather than assigned during render. The listener fires between renders; a ref that only catches up on the next one can misreport which state it is describing. resetMode() is now the single place the mode is torn down, shared by clearMode, the failed-batch callback, the background disarm and the pane boundary.
…e notice to the next pane Review follow-up on the commit below, two things. The arm-generation token guarded nothing. activate() already cancels a pending blur, and it is the only way a session re-arms, so the token could never differ by the time the timer ran — and the test written for it passed against the unfixed hook and stayed green with the guard deleted. Ownership is settled by CANCELLATION alone now: every transition that can take the field (re-arm, pane change, unmount, a second disarm) cancels the pending blur first, so an old one cannot reach a live session because it no longer exists. The activeRef bail went with it — kept as belt-and-braces it silently absorbed the mutation, which meant removing the line that actually does the work still left the suite green. One mechanism, and the test now fails without it (verified by deleting the line). `backgrounded` was never cleared at a pane boundary, so the notice was owed by the hook rather than by the pane that earned it. A pane can change WHILE hidden — push notifications deep-link into one — and the return trip then announced "Stopped typing into the terminal — the app was backgrounded" over a pane the mode was never armed on. Cleared alongside the pending blur, with a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merged — thank you. The diagnosis was the valuable part: a status published while the page is hidden expires unseen, so the disarm had to announce itself on the way back rather than on the way out, and blurring the field matters as much as the message does. Two follow-ups landed on top (1a2ca49). The arm-generation token was removed — Ships in the next release; no bump needed on your side, and you had the fork rules exactly right. |
The bug
Backgrounding the page disarms Type into terminal — correctly: the mirror has stopped tracking the pane, so the next keystroke would land in a terminal nobody is looking at.
It disarmed silently.
clearMode()ran with no message, and a status published while the document is hidden expires unseen anyway (lib/status.tsdrops a non-error after 2.5s). It also left the field focused with the keyboard up.So: you switch apps mid-command, come back to something that looks exactly like the mode you left, keep typing — and the characters go into the reply draft. A different action, with an eventual Enter attached to it.
Reproduce on a phone: arm Type into terminal, switch apps, come back, type. On
mainthe text silently becomes a reply.The fix
visibleevent. A message published while hidden is gone before anyone can read it.active. Hiding the document is what disarms, so an effect keyed on the armed state tore its own listener down in the same commit — the return trip had nobody left to report it.focusInputEnd()is itself a timeout, so a synchronous blur is undone by a focus already queued), which means it can land after the field has changed hands. It now carries the arm generation it was scheduled for, no-ops once a re-arm has taken over, and is cancelled at a pane boundary and on unmount. The failed-batch path had the same unowned deferred blur and now shares the guarded one.resetMode()is now the single teardown, shared byclearMode, the failed-batch callback, the background disarm and the pane boundary.Tests
7 new tests in
composer.test.tsx, each failing before the change:Each was proved to catch the old behaviour by reverting the hook under the new test.
bunx tsc --noEmitclean.composer.test.tsx: 79 pass / 2 fail — the same 2 fail onmain(restores the draft after a remount,keeps drafts per pane); they're localStorage-less-environment failures, not from this change.Versioning
Per
CLAUDE.md, fork PRs leave the version files and CHANGELOG alone. Suggested entry if you want one: