Skip to content

fix(ChatWindow): make iOS conversations scrollable#2418

Closed
gary149 wants to merge 3 commits into
mainfrom
fix/ios-conversation-scroll
Closed

fix(ChatWindow): make iOS conversations scrollable#2418
gary149 wants to merge 3 commits into
mainfrom
fix/ios-conversation-scroll

Conversation

@gary149

@gary149 gary149 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Two fixes for conversation scrolling on iOS Safari, both reproduced and verified on the iOS 18.3 Simulator with scroll telemetry injected into a sandbox build.

1. Conversations taller than the viewport could not scroll at all

The inner messages wrapper used h-full, which clamps it to the scroll container height. WebKit (unlike Blink) does not count that fixed-height flex child's overflow toward the container scrollHeight, so scrollHeight === clientHeight and the container reports nothing to scroll. New messages and their responses landed off-screen and were unreachable.

Fix: h-full to min-h-full on the mx-auto wrapper (ChatWindow.svelte). It still fills the viewport when content is short (intro stays centered) and grows when content is taller.

Measured: before scrollHeight === clientHeight (595/595), touch scroll inert; after scrollHeight 4296 vs 595 and touch scrolling works.

2. Thinking-block collapse killed follow-the-stream

When reasoning ends mid-stream, the thinking block auto-collapses (OpenReasoningResults), briefly shortening the layout. iOS Safari applies queued scrollTo calls asynchronously and out of order: a clamp computed against that transient short layout can land 100ms+ late. snapScrollToBottom's scroll handler read the resulting scrollTop drop as the user scrolling up, detached, and the streaming answer landed below the fold with no way back (measured stranded 391px below, permanently).

Fix in snapScrollToBottom.ts, based on the observation that scroll-event detach is a scrollbar-drag signal which only exists on fine pointers, while on touch devices every real user scroll raises touch events first:

  • detach from scroll events only on fine-pointer devices, and not when the scrollTop drop coincides with a scrollHeight change (layout shift, e.g. the collapse)
  • on touch devices, honor up-scrolls within 2.5s of touch contact as user intent (momentum tail after the touch debounce)
  • correct phantom up-scrolls that arrive within 2s of programmatic scrolling while following
  • verify position after paint in scrollToBottom and re-issue once if a stale application stomped it

Verified on the simulator: view stays pinned through expand, collapse, streaming, and post-stream phantom scrolls (final distance-from-bottom 0 vs 391 before); a user swipe up mid-stream still detaches and holds with no snap-back, including through its momentum tail.

3. Thinking-block collapse jumped the viewport

The auto-collapse when the answer starts removed ~230px in one frame (Safari has no scroll anchoring), and the process-to-nested branch swap in ChatMessage flashed the uncapped static reasoning for one frame (measured up to 1400px) before tearing it down. Frame-by-frame measurement showed a 188px single-frame yank plus the flash.

Fixes: a 180ms slide on the block's open/collapse so the spacer and follower track it frame by frame; the uncapped static prose renders only for manual opens (the automatic cycle stays in the capped streaming viewport, so the loading flip has nothing to flash); and manual scroll anchoring at the branch swap (compensate the container for the content height delta in the same pre-paint flush).

Measured after: no flash, worst single-frame shift is a sub-100px ripple across a few frames, and the view stays pinned to the stream start to finish (final distance-from-bottom 0).

gary149 added 3 commits July 3, 2026 13:41
The inner messages wrapper used h-full, which clamps it to the scroll
container height. WebKit (unlike Blink) does not count that fixed-height
flex child's overflow toward the container scrollHeight, so
scrollHeight === clientHeight and the conversation cannot scroll on iOS
Safari. Use min-h-full so the wrapper still fills the viewport when
content is short but grows (and reports real height) when it is taller.
…llapse on iOS

When reasoning ends mid-stream the thinking block auto-collapses, briefly
shortening the layout. iOS Safari then applies queued scrollTo calls out of
order (a clamp computed against the transient short layout can land 100ms+
late), which the scroll handler misread as the user scrolling up: it detached
and the streaming answer landed below the fold with no way back.

Scroll-event detach is a scrollbar-drag signal that only exists on fine
pointers; on touch devices every real scroll raises touch events first. So:
- only detach from scroll events on fine-pointer devices, and not when the
  scrollTop drop coincides with a scrollHeight change (layout shift)
- on touch devices, honor up-scrolls within 2.5s of touch contact as user
  intent (momentum tail), and correct phantom up-scrolls that arrive within
  2s of programmatic scrolling while following
- verify scroll position after paint in scrollToBottom and re-issue once if
  a stale application stomped it
…wport on iOS

Safari has no scroll anchoring, so the auto-collapse when the answer starts
removed a couple hundred px in a single frame and the viewport visibly
jumped; when the process phase ended, the branch swap in ChatMessage also
flashed the uncapped static reasoning for one frame (measured up to 1400px)
before tearing it down.

Three parts:
- animate the open/collapse with a 180ms slide so the spacer and
  snapScrollToBottom track the height change frame by frame
- render the uncapped static prose only for manual opens; the automatic
  open -> collapse cycle stays in the capped streaming viewport, so the
  loading flip never paints a full-height flash frame
- when isProcessStreaming flips off, compensate the scroll container for
  the content height delta in the same pre-paint flush (manual scroll
  anchoring for the streaming -> nested branch swap)

Measured on the iOS 18.3 simulator (frame-by-frame sentinel tracking):
worst single-frame shift at the collapse goes from 188px (plus the 1400px
flash) to a sub-100px ripple over a few frames, and the view stays pinned
through the whole answer stream.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 949b67c05b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +54 to +55
const isCoarsePointer =
typeof window !== "undefined" && window.matchMedia("(any-pointer: coarse)").matches;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat hybrid devices as scrollbar-capable

On hybrid devices such as Windows touch laptops, (any-pointer: coarse) is true even though a fine pointer and draggable scrollbar are also present. Because handleScroll later suppresses the scrollbar-drag detach path whenever isCoarsePointer is true, dragging the chat scrollbar upward on those devices is ignored and subsequent streaming resizes keep snapping back to the bottom. Use a coarse-only check or also account for (any-pointer: fine) before disabling scrollbar detection.

Useful? React with 👍 / 👎.

Comment on lines +113 to +115
if (!isDetached && !userScrolling && distanceFromBottom() > BOTTOM_THRESHOLD) {
lastProgrammaticScrollTime = Date.now();
node.scrollTo({ top: node.scrollHeight });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve smooth reattach scrolls

When forceReattach requests a smooth scroll for desktop sends, the scroll is expected to still be more than BOTTOM_THRESHOLD from the bottom on the very next animation frame. This new correction then issues a second scrollTo without the original behavior, so every smooth reattach gets snapped after one frame instead of animating. Gate this stale-scroll correction away from active smooth scrolls or wait until the smooth scroll has had a chance to finish.

Useful? React with 👍 / 👎.

@gary149 gary149 closed this Jul 3, 2026
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