Skip to content

web-ui: iOS composer focus no longer scrolls the chat to the top - #1103

Draft
ReganBell wants to merge 1 commit into
mainfrom
qm-fix/mobile-composer-scroll
Draft

ReganBell wants to merge 1 commit into
mainfrom
qm-fix/mobile-composer-scroll

Conversation

@ReganBell

@ReganBell ReganBell commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Symptom

On iOS, tapping the composer can scroll the page so the composer sits at the very top of the screen and the chat is scrolled away (screenshot in the session-debug brief).

Root cause

Two defects in trackVisualViewport (plugins/web-ui/src/viewport.ts):

  1. The near-bottom check ran too late. The restore-to-bottom of .chat-scroll only ran at the kbd-open transition, sampling scrollTop after Safari's focus-reveal scrolling had already moved both the window and the chat container. The check usually read false → no restore → the chat stayed wherever Safari dumped it.
  2. Single-shot handling of a multi-event fight. iOS fires focus-reveal scrolls, visualViewport resizes and pans in device-dependent order — sometimes panning the visual viewport without any event we listen to, and window.scrollTo(0,0) cannot undo a pure visual-viewport pan. A stale --vv-top/--vvh/window-scroll combination (layout translated while the viewport isn't panned, or vice versa) renders exactly the reported state: composer strip at the top, blank page below, chat off-screen — and nothing ever self-heals.

Fix

  • Capture the chat anchor at composer focusin — before the keyboard opens: either pinned to bottom (was within 160px) or the reader's exact scroll position.
  • Run a short rAF settle loop (~900 ms) after focus/keyboard transitions that re-asserts the anchor, window.scrollTo(0,0), and fresh --vvh/--vv-top every frame until iOS stops fighting — covering the event orders that fire nothing we can hook.
  • The user's own touchstart ends enforcement immediately, so we never fight a real scroll gesture.
  • Readers not near the bottom now get their position restored (previously they were left wherever Safari's reveal scroll dumped them).

Tests

plugins/web-ui/test/viewport.test.ts (jsdom + fake visualViewport, deterministic rAF queue):

  • existing anchor-on-every-event test adapted;
  • regression test: anchor captured at focusin survives Safari mangling scrollTop before the resize lands → chat re-pinned to bottom;
  • reader far from bottom gets their position back;
  • touch cancels enforcement.

Typecheck (app/server/test tsconfigs) and web-ui suite pass: 1023/1023.

Manual iOS repro notes (no jsdom for real viewport races)

  1. iPhone Safari, any chat with enough history to scroll; scroll to the bottom.
  2. Tap the composer. Before: page occasionally ends with composer pinned at top / chat scrolled to top (more likely with the keyboard already dismissed-and-retapped quickly, or with autofill bar present). After: chat stays pinned above the keyboard.
  3. Scroll up ~2 screens, tap composer: reading position must be preserved (new behavior).
  4. While the settle window is active, flick the chat — enforcement must yield instantly.

Draft from the session-debug fork sweep (fork 3); do not merge without review.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Tapping the composer on iOS could leave the page scrolled so the composer sat
at the top of the screen with the chat scrolled away entirely. Two defects in
trackVisualViewport conspired:

- 'near bottom' was sampled at the kbd-open TRANSITION — after Safari's
  focus-reveal scrolling had already mangled both the window scroll and the
  .chat-scroll position — so the restore-to-bottom usually never fired and the
  chat stayed wherever Safari dumped it.
- everything was single-shot per viewport event, while iOS fires focus-reveal
  scrolls, visualViewport resizes and pans in device-dependent order, sometimes
  without any event we listen to. A stale --vv-top/--vvh/window scroll never
  self-healed.

Now the chat anchor (pinned-to-bottom, or the reader's scroll position) is
captured at composer focusin — before the keyboard opens — and a short
rAF settle loop (~900ms) re-asserts the anchored state, window scroll and
viewport CSS vars until iOS stops fighting. The user's own touch ends
enforcement immediately.

jsdom tests cover the anchor capture, reader-position restore, and
touch-cancels-enforcement; manual iOS repro notes in the PR.
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