Skip to content

fix(chat): keep the soft keyboard closed when a session becomes active on touch - #865

Merged
xintaofei merged 3 commits into
xintaofei:mainfrom
Flyneen:fix/mobile-composer-autofocus-on-tab-switch
Sep 30, 2026
Merged

xintaofei merged 3 commits into
xintaofei:mainfrom
Flyneen:fix/mobile-composer-autofocus-on-tab-switch

Conversation

@Flyneen

@Flyneen Flyneen commented Sep 30, 2026

Copy link
Copy Markdown

Problem

On a phone, switching to another session pops the soft keyboard over the transcript.

MessageInput focuses the composer whenever its tab becomes active:

useEffect(() => {
  if (isActive && composerReady && !isPrompting) {
    requestAnimationFrame(() => editorRef.current?.focus())
  }
}, [isActive, composerReady, isPrompting])

On desktop that is exactly what you want. On a touch device focusing a contenteditable raises the soft keyboard, so a switch that was meant to read another session's progress hides it instead — and the keyboard has to be dismissed by hand every time.

Reproduced on the 0.32.0 / 0.32.4 web build (LAN web service) in a touch-emulated browser: after choosing another session from the mobile session list, document.activeElement becomes the composer's contenteditable within ~400ms, i.e. the keyboard comes up.

Fix

Skip that auto-focus when the primary pointer is coarse, using the same useIsCoarsePointer signal the tab bar (tab-bar.tsx, file-workspace-tab-bar.tsx) already uses for touch-only behaviour.

Tapping the composer still focuses it exactly as before — natively inside the text, and through the chrome-press handler in the padding — so the keyboard comes up on demand instead of on tab activation. A fine pointer keeps today's behaviour: opening a tab still lands the caret in the composer.

Tests

Two cases added to src/components/chat/message-input.test.tsx:

  • coarse pointer + isActive → the composer is not focused
  • fine pointer + isActive → the composer is focused (guards the desktop behaviour)

Validation

  • pnpm lint (eslint + prettier) — clean on the touched files
  • pnpm test — full suite green: 538 files, 8019 tests
  • tsc --noEmit — clean

Notes

Verified against the built app as well: with the change applied the composer stays unfocused through a real session switch (the transcript is left visible), while tapping the composer focuses it and typing works — on both a phone-sized touch viewport and a desktop viewport.

…e on touch

Switching to another session pops the soft keyboard over the transcript on a
phone. `MessageInput` auto-focuses the composer whenever its tab becomes
active, and focusing a contenteditable raises the keyboard — so a switch that
was meant to read another session's progress hides it instead, and the keyboard
has to be dismissed by hand.

Skip that auto-focus when the primary pointer is coarse, using the same
`useIsCoarsePointer` signal the tab bar already uses for touch-only behaviour.
Tapping the composer still focuses it exactly as before — natively inside the
text, through the chrome-press handler in the padding — so the keyboard comes up
on demand rather than on tab activation. A fine pointer keeps the current
behaviour: opening a tab still lands the caret in the composer.

Covered by two cases in message-input.test.tsx: a coarse pointer leaves the
composer unfocused when the tab becomes active, a fine pointer still focuses it.

Validation: eslint clean, tsc --noEmit clean, full vitest suite green
(538 files / 8019 tests).
@Flyneen

Flyneen commented Sep 30, 2026

Copy link
Copy Markdown
Author

Ready for review.

Local validation on this branch: eslint clean on both touched files, tsc --noEmit clean, and the full vitest suite green (538 files / 8019 tests).

Behaviour verified against the built app as well (touch-emulated browser on the LAN web service): with the gate in place a real session switch leaves the composer unfocused 鈥?the transcript stays visible, no keyboard 鈥?while tapping the composer focuses it and typing works; a desktop viewport keeps the previous auto-focus behaviour.

…ests

The coarse-pointer case asserted one frame after the editor mounted, before
the composer was even ready, so it passed with the gate removed. Both pointer
kinds now seed a draft, wait for it to be restored (the restore and the
auto-focus are scheduled by the same render, in that order), then wait the
frame Tiptap's focus command defers the DOM focus to, and assert. The
fine-pointer side proves those frames are enough, so the coarse side cannot
pass by looking too early.

Also covers switching to a session already open in the background, the
isActive false -> true path a switch to an open tab takes. That case first
checks the background composer holds no focus, so the focus seen after the
switch is the switch's doing.
…focus

The auto-focus gate subscribed every mounted composer to the coarse-pointer
media query. Open tabs keep their composers mounted, so each one held a
listener, and a pointer change re-rendered all of them and re-ran the effect,
which moved focus into the active composer whenever the pointer turned fine
(a 2-in-1 docking to its mouse).

The effect now asks `isPrimaryPointerCoarse()` at the moment it would focus.
The composer holds no listener, a pointer change renders nothing, and the
effect is back to its original dependencies. Its pending frame is also
cancelled when the tab goes inactive or a turn starts before it runs, as the
draft restore above already does, so a tiled group's deactivated composer
cannot take the caret.

The tests stub the browser's media query rather than mocking the hook, so
they hold whichever way the pointer is read, and cover both changes.
@xintaofei

Copy link
Copy Markdown
Owner

codeg work task 269 is done — #865 (3 files, +167/-53).

@xintaofei
xintaofei merged commit 054f783 into xintaofei:main Sep 30, 2026
7 checks passed
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