fix(ChatWindow): make iOS conversations scrollable#2418
Conversation
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.
There was a problem hiding this comment.
💡 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".
| const isCoarsePointer = | ||
| typeof window !== "undefined" && window.matchMedia("(any-pointer: coarse)").matches; |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!isDetached && !userScrolling && distanceFromBottom() > BOTTOM_THRESHOLD) { | ||
| lastProgrammaticScrollTime = Date.now(); | ||
| node.scrollTo({ top: node.scrollHeight }); |
There was a problem hiding this comment.
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 👍 / 👎.
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 containerscrollHeight, soscrollHeight === clientHeightand the container reports nothing to scroll. New messages and their responses landed off-screen and were unreachable.Fix:
h-fulltomin-h-fullon themx-autowrapper (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; afterscrollHeight4296 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 queuedscrollTocalls 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:scrollToBottomand re-issue once if a stale application stomped itVerified 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).