Skip to content

fix(direct-typing): say the mode stopped when the app comes back - #108

Merged
AltanS merged 3 commits into
AltanS:mainfrom
enieuwy:fix/direct-typing-background-notice
Aug 17, 2026
Merged

fix(direct-typing): say the mode stopped when the app comes back#108
AltanS merged 3 commits into
AltanS:mainfrom
enieuwy:fix/direct-typing-background-notice

Conversation

@enieuwy

@enieuwy enieuwy commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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.ts drops 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 main the text silently becomes a reply.

The fix

  • Defer the notice to the next visible event. A message published while hidden is gone before anyone can read it.
  • Mount the visibility listener for the hook's whole life, not 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 — the return trip had nobody left to report it.
  • Put the keyboard away on disarm. The notice expires; a focused field does not. A primed field outlasting the only warning is exactly how terminal keystrokes become a buffered reply.
  • Own the deferred blur. It has to be deferred (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.
  • Write the armed flag at the transitions, not during render. The listener fires between renders; a ref that catches up on the next one can misreport which state it is describing. resetMode() is now the single teardown, shared by clearMode, the failed-batch callback, the background disarm and the pane boundary.

Tests

7 new tests in composer.test.tsx, each failing before the change:

  • the deferred announce on return,
  • blur-on-background,
  • the notice surviving to a visible page,
  • a stale blur not reaching into a re-armed session.

Each was proved to catch the old behaviour by reverting the hook under the new test.

bunx tsc --noEmit clean. composer.test.tsx: 79 pass / 2 fail — the same 2 fail on main (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:

  • "Type into terminal" now says when it stopped. Backgrounding the app disarms the mode; it did so silently and left the field focused, so the next thing you typed became a reply draft instead (#NN, 452da20)

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.
AltanS and others added 2 commits August 17, 2026 22:21
…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>
@AltanS
AltanS merged commit 8295e80 into AltanS:main Aug 17, 2026
1 check passed
@AltanS

AltanS commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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 — activate() already cancels the pending blur and is the only re-arm path, so the token could not differ by the time the timer ran, and the test written for it passed against the unfixed hook too. Ownership is settled by cancellation alone now, and the test fails without it. And backgrounded is now cleared at the pane boundary: a pane can change while the page is hidden (push notifications deep-link into one), which had the return trip announcing the disarm over a pane the mode was never armed on.

Ships in the next release; no bump needed on your side, and you had the fork rules exactly right.

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.

2 participants