fix(desktop): keep the composer's focus and insert buses alive while the gateway is connecting - #74571
Conversation
…the gateway is connecting
`useComposerDraft`'s bus effect returned early whenever `inputDisabled`,
which unsubscribed BOTH `onComposerFocusRequest` and
`onComposerInsertRequest`. `inputDisabled` is `disabled && !reconnecting`,
i.e. exactly `gatewayState === 'connecting'` — every cold boot, every
reconnect attempt, and every profile/gateway switch.
Nothing on the publishing side stops during that window, and the keybind
layer has already committed to the keystroke: use-keybinds.ts calls
`event.preventDefault()` and *then* `requestComposerFocus('active',
{ typeChar })`. With no subscriber the character is not deferred, it is
destroyed. The same holds for external inserts. After the first successful
boot the connecting overlay is deliberately not shown, so the user is
looking at a mounted, visible composer that silently eats what they type.
The gate was accidental, not deliberate: the sibling
`onComposerInsertRefsRequest` subscription in this same hook has always
been unconditional, so ref-chip inserts already survived the window while
plain text and keystrokes did not. `focus.ts`'s own `resolveActive`
docstring names the symptom ("preventDefault'd and dropped").
Stay subscribed and let the existing deferral do its job: the
`if (!inputDisabled) focusInput()` effect lists `inputDisabled` in its
deps, so it re-runs the moment the gateway opens and focus lands then.
`paintDraft` is passed `!inputDisabled` so a mid-connect keystroke does not
raise a focus request against an editor that is not yet contentEditable.
The deliberate submit gate in `use-composer-submit.ts` and
`contentEditable={!inputDisabled}` are untouched — editability and submit
availability stay split.
|
Thanks for the focused fix. The premise remains present on current main: The patch removes only that listener gate while preserving the focus deferral already implemented at This is an automated hermes-sweeper review. |
This is a sibling follow-up to #73881 (
f12e6526a)'active'onto the visible chat surface, so a type-to-focus request can no longer be routed to a composer buried behind a keep-alive tab.use-composer-draft.tsreturned early whileinputDisabled, andtargetIsReachable()still reports that surface live because the composer is mounted and stampsdata-composer-target— so healing resolves to a live surface with a dead subscriber.What does this PR do?
useComposerDraft's bus effect opened with an early return that unsubscribed bothonComposerFocusRequestandonComposerInsertRequestwheneverinputDisabledwas true:inputDisabledisdisabled && !reconnectingwheredisabled = !gatewayOpenandreconnecting = gatewayState === 'closed' || gatewayState === 'error'(composer/index.tsx,chat/index.tsx). So it is true exactly whilegatewayState === 'connecting'— every cold boot, every reconnect attempt (closed → connecting → open), and every profile / gateway switch.Nothing on the publishing side pauses during that window, and the keybind layer has already committed to the keystroke —
app/hooks/use-keybinds.tscallsevent.preventDefault()and thenrequestComposerFocus('active', { typeChar })(same for the soft/). With no subscriber the character is not deferred, it is destroyed. External inserts (use-composer-actions.ts,right-rail/preview-file.tsx,use-desktop-integrations.ts,clarify-tool.tsx) are dropped the same way.This is user-visible rather than a hidden edge case:
components/gateway-connecting-overlay.tsxonly shows its blocking overlay while!coldBootDoneRef.current && !gatewaySwitching && initialBootActive, and its comment states the intent outright — after a healthy boot "Do not cover the chat then — users should still be able to type drafts". So on every reconnect after the first boot the user faces a mounted, visible composer showing the "connecting" placeholder, types into it, and the text vanishes.The gate was accidental, not deliberate. The sibling
onComposerInsertRefsRequestsubscription in this same hook has always been unconditional, so ref-chip inserts already survived the connecting window while plain text and keystrokes did not — same hook, same file, same bus family, opposite policy. Andcomposer/focus.ts'sresolveActivedocstring already names this exact symptom in main's own words: "with no subscriber on the visible surface, every type-to-focus keystroke is preventDefault'd and dropped."Why stay subscribed rather than defer
preventDefault()on the keybind side. The deferral machinery already exists and is already correct —useEffect(() => { if (!inputDisabled) { focusInput() } }, [focusInput, focusKey, focusRequestId, inputDisabled])listsinputDisabledin its deps, so it re-runs the moment the gateway opens and focus lands then. Keeping the fix inside this one hook also leavescontentEditable={!inputDisabled}and the deliberate submit gate inuse-composer-submit.tsuntouched, so #45488's split of editability from submit availability is preserved.paintDraftis passed!inputDisabledinstead of a hardcodedtrueso a mid-connect keystroke does not raise a focus request against an editor that is not yetcontentEditable; the effect above is the single focus authority. (Note for the reviewer: this argument is defensive/documentary — because that effect is itself gated on!inputDisabled, a hardcodedtrueproduces the same end state, minus a redundantfocusRequestIdbump and re-render per mid-connect keystroke, which this file's design notes explicitly try to avoid. The behavioural fix is the removal of the early return, and that is what the tests pin.)Sibling-site sweep
onComposerFocusRequest/onComposerInsertRequest/onComposerInsertRefsRequest/onComposerSubmitRequest/onComposerVoiceToggleRequestand everyinputDisabledreference underapps/desktop/src:use-composer-draft.tsbus effect (gates both focus + insert)use-composer-draft.tsonComposerInsertRefsRequestuse-composer-submit.tsonComposerSubmitRequest+ inner!inputDisableduse-composer-voice.tsonComposerVoiceToggleRequestuser-edit-composer.tsxfocus + insert subscriptionsuse-composer-draft.tsif (!inputDisabled) focusInput()composer/index.tsxaria-disabled/contentEditable={!inputDisabled}One site, two buses. No other subscription in the tree shares this root cause.
Related Issue
No filed issue — this was found by tracing the
preventDefault()-then-dispatch path thatfocus.ts'sresolveActivedocstring describes. Context instead:'active'-claim half of the dropped-keystroke problem; this is the no-subscriber half.Type of Change
Changes Made
apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts— dropped theif (inputDisabled) { return undefined }early return so the focus and insert buses stay subscribed while the gateway is connecting; pass!inputDisabledaspaintDraft'sfocusargument; comment recording why the subscription must outliveinputDisabled.apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx— newdescribewith 4 cases and aConnectingHarnessthat binds a realcontentEditableto the hook'seditorRefand takesinputDisabledas a prop. The existingProbeHarnessis untouched (it rendersnulland hardcodesinputDisabled: falsefor the [Desktop] Chat tab messages leak across sessions — cross-tab content mixing #59305 attachment-scope test).How to Test
Automated:
8 passed (8)— 4 new, 4 pre-existing. The whole composer directory is28 files / 219 passed, and the full desktopuiproject is339 files / 3017 passed.Fails before the fix (verified both ways, not assumed). Restoring
if (inputDisabled) { return undefined }turns exactly the three new behavioural cases red and leaves the fourth (the gateway-already-open guard) green:The empty-string results are the point: without a subscriber the dispatched event reaches nobody and the draft never receives the character.
One trap worth flagging for anyone extending these tests:
focus.ts'sdispatchdefers throughwindow.setTimeout(…, 0), and no pre-existing test in this file exercises that path (they all use the synchronousmarkActiveComposer/getActiveComposerhelpers). Without draining the macrotask the assertions pass vacuously — "the draft is empty" would hold because the event was never delivered at all. The new cases go through aflushBus()helper, and the red-before run above is what proves they are not vacuous.Manual:
closed → connecting. The composer stays visible with the "connecting" placeholder.open.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — N/A, this is desktop TypeScript; the vitest runs above are the equivalent gate. (Heads-up unrelated to this PR:Python tests / Run tests slice 8/8and the aggregateAll required checks passgate are currently red on cleanorigin/mainitself.)Documentation & Housekeeping
docs/, docstrings) — the changed behaviour is documented in-file at the subscriptioncli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/ARelated / Positioning
Two other open PRs touch
use-composer-draft.ts. Neither is a duplicate of this one, and I'd rather name them than let a reviewer find them:prevent composer draft leak on new session) — disjoint. Its hunks are at the import block and the per-session stash path; its onlyinputDisabledoccurrence is a test-harness prop.discard draft on explicit new session) — edits this exact effect, for a different bug. It adds a third subscription,onComposerClearRequest, inside the same effect, withoffClear()in the cleanup. It does not remove the early return, so it neither duplicates nor supersedes this change — but it is worth noting that its new subscription lands inside the very gate this PR removes, and therefore inherits this same defect: a clear request published while the gateway is connecting would find no subscriber. Removing the early return here makes that pending subscription correct too. The textual overlap is a few lines and this diff was deliberately kept minimal so either order of merging resolves trivially.I deliberately did not touch
app/hooks/use-keybinds.tsorcomposer/focus.ts, even though thepreventDefault()call and theresolveActivedocstring both live there — #74545, #73948, #72959 and #74406 are actively reworking those two files, and this bug is fixable entirely on the subscriber side.