Skip to content

Replace scroll-to-bottom action with position-based controller#2423

Merged
gary149 merged 2 commits into
mainfrom
claude/scroll-manipulation-rewrite-kxv4uv
Jul 5, 2026
Merged

Replace scroll-to-bottom action with position-based controller#2423
gary149 merged 2 commits into
mainfrom
claude/scroll-manipulation-rewrite-kxv4uv

Conversation

@gary149

@gary149 gary149 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces the fragile snapScrollToBottom action with a new StickToBottomController that uses position-based scroll attribution instead of timers. This eliminates false detachments, handles all user input types correctly (scrollbar drags, keyboard, find-in-page, selection auto-scroll), and provides a unified system for chat-specific scroll anchoring.

Key Changes

  • New StickToBottomController (src/lib/utils/scroll/stickToBottom.ts): Core scroll controller that:

    • Records expected scrollTop values for programmatic writes and matches them against scroll events to distinguish user input from browser clamps
    • Owns exactly one scroll/wheel/touch/keydown listener, one ResizeObserver, and one rAF animation loop
    • Supports spring and instant follow modes with reduced-motion awareness
    • Handles iOS rubber-banding, fractional zoom rounding, and momentum scroll edge cases
  • New ChatScroll orchestrator (src/lib/utils/scroll/chatScroll.svelte.ts): Chat-specific layer that:

    • Manages explicit send/retry intents armed at call sites (not inferred from message diffs)
    • Owns the send-anchor spacer element and its measurement-driven height
    • Exposes reactive state for floating scroll buttons (no listeners needed on buttons themselves)
    • Handles conversation switches and branch switches with proper intent lifecycle
  • Spacer geometry module (src/lib/utils/scroll/spacer.ts): Computes send-anchor spacer height to keep sent messages at a fixed viewport offset during the fill phase (constant scrollHeight, zero layout shift)

  • Comprehensive test suite:

    • stickToBottom.svelte.test.ts: 40+ tests covering mount, follow, detach, gestures, animations, streaming
    • chatScroll.svelte.test.ts: 20+ tests for send anchoring, fill phase, handoff to follow, intent lifecycle
    • harness.ts: Browser UX test harness with real DOM, rAF-cadence streaming, and simulated user input (wheel, touch, scrollbar, keyboard)
    • spacer.spec.ts: Geometry computation tests
  • Updated components:

    • ChatWindow.svelte: Replaced snapScrollToBottom action with createChatScroll() and explicit armSend() calls
    • ScrollToBottomBtn.svelte & ScrollToPreviousBtn.svelte: Simplified to accept visible and onclick props (state now owned by ChatScroll)
    • ArtifactPanel.svelte: Uses StickToBottomController directly for artifact streaming
    • ChatMessage.svelte: Added preventScroll to edit focus to avoid reveal-scroll side effects
  • New constants (src/lib/constants/gestures.ts): NAV_EDGE_SWIPE_ZONE_PX for edge-swipe gesture handling

  • Removed: Old snapScrollToBottom action (308 lines)

Implementation Details

Position-based attribution: Every programmatic write records the scrollTop it expects the next scroll event to report. A scroll event matching a pending write is recognized as ours; one matching a browser clamp after content shrank is neither ours nor the user's; everything else is user input by construction. This correctly classifies scrollbar drags, keyboard scrolling, find-in-page, and selection auto-scroll without grace periods.

Send anchoring: When a send intent is armed, the spacer inflates to position the sent message at a fixed offset below the viewport top. As the reply streams in, the spacer shrinks 1:1, keeping scrollHeight constant and preventing layout shift. Once the spacer reaches its floor, scrollHeight grows and stick-to-bottom follow takes over.

Spring animation: Uses exponential decay (80ms time constant) with minimum speed floor and snap-to-target threshold. Long jumps (>2500px) teleport most of the way first to avoid crawling. Degrades to instant jumps for reduced-motion users and hidden tabs.

Gesture handling: Distinguishes wheel up (un

https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW

claude added 2 commits July 4, 2026 21:50
The chat scroll experience was four uncoordinated systems fighting over
one container: the snapScrollToBottom action (whose ResizeObserver
watched an h-full column that never resizes, leaving streaming autoscroll
dead on every first exchange), ChatWindow's spacer observer doubling as
the de-facto autoscroller without seeing the action's detach state, and
two floating buttons with their own listeners — one self-destructed by a
broken $effect.root pattern. Time-based scroll attribution (100ms grace
windows) misclassified the tail of every smooth scroll, branch switches
to shorter branches teleported the view to the bottom via clamp events
read as user input, and editing the first message was misdetected as a
conversation switch.

Replace all of it with:

- StickToBottomController: one scroll/wheel/touch/keydown listener each,
  one ResizeObserver, one rAF spring loop. Programmatic scrolls are
  attributed by expected position instead of timers, so scrollbar drags,
  keyboard scrolling and find-in-page classify correctly by construction;
  browser clamps (shorter branch, collapsing reasoning) and native
  scroll-anchoring adjustments are recognized by their geometric
  signatures. The spring retargets the live bottom every frame, so
  following can never land short, and wheel/touch fast paths halt it the
  same frame the user pushes back. Reduced motion and hidden tabs get
  instant jumps. Inner scrollables (code blocks) no longer cause false
  detaches, and the mobile nav's edge-swipe zone is ignored.

- ChatScroll glue: explicit send/retry/branch-switch intents armed where
  the events happen, replacing message-array-diff heuristics. The
  ChatGPT-style send anchor keeps scrollHeight constant while the reply
  fills the viewport (zero motion, zero layout shift) and hands off to
  the spring when it overflows. The spacer floor tracks the real composer
  height, so a tall draft can no longer occlude the reply. Conversation
  switches key on the route param. Buttons are stateless views with
  aria-labels, gated on detachment with show/hide hysteresis.

- Container hardening: scrollbar-gutter reserves symmetric gutters (no
  4px shift when a conversation first overflows) with a measured CSS
  variable keeping the composer aligned; tabindex + label make the
  primary scroll region keyboard-accessible; visualViewport resizes
  (iOS keyboard) feed the controller.

- ArtifactPanel's hand-rolled code-view autoscroll becomes a second
  controller instance in instant mode, gaining intent-aware detach,
  resize observation and re-attach catch-up.

Tested with a browser-mode UX harness (rAF-cadence streaming simulator,
wheel/scrollbar/touch/keyboard input simulation, PerformanceObserver
layout-shift probe, seeded fuzz over the attribution invariants): 44
browser tests plus pure-geometry node tests, all green alongside the
existing suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW
Eight independent review passes over the rewrite surfaced one real
regression and a set of correctness and efficiency gaps:

- The artifact code view observed the inline <code> element, and
  ResizeObserver never fires for non-replaced inline elements — the
  streaming follow silently died after the initial pin. The element is
  now block-level, with the constraint documented at both sites.
- Clamps (content shrink, viewport growth) now re-engage following:
  a clamp by definition lands the view at the exact bottom, and leaving
  a detached user there while the stream continued below the fold was a
  regression from the old sentinel behavior.
- Intent hardening: a branch switch can no longer consume an armed
  send/retry intent (an empty errored sibling is structurally identical
  to a fresh pair, so the switch itself now vetoes consumption), its
  programmatic unpin no longer revokes the pending pin, and a send whose
  first token lands in the same flush as the pair mount is recognized by
  its exact +2 growth instead of being dropped.
- A two-finger pinch no longer reads as scroll intent; keydowns already
  consumed by widgets (defaultPrevented, selects) are ignored; the
  left-edge swipe zone is a controller option wired to the same constant
  MobileNav uses instead of a magic 40 baked into a generic class.
- The pending placeholder (rendered before any message exists) gets its
  own observed wrapper, and the server-rendered markup regains the
  spacer's composer-clearance height so nothing is occluded pre-hydration.
- Streaming hot path: the send-anchor element is resolved once per turn
  instead of a full subtree query per frame, gutter measurement runs only
  on container resizes, wheel handlers check pin state before the
  ancestor walk, scroll events take one geometry snapshot, and pin
  transitions notify subscribers exactly once.
- Composer/message column alignment variable is now owned by ChatWindow
  markup via reactive state instead of an imperative write to a parent
  element the module was never handed.

Suite grows to 50 browser tests (pinch, edge-zone, defaultPrevented,
clamp re-pin, branch-switch intent immunity, same-flush send).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW
@gary149 gary149 merged commit be4e935 into main Jul 5, 2026
6 checks passed
@gary149 gary149 deleted the claude/scroll-manipulation-rewrite-kxv4uv branch July 5, 2026 10:31
gary149 added a commit that referenced this pull request Jul 14, 2026
…manual anchoring (#2427)

* Stop the answer-start re-anchor jump: one-way spacer while streaming

When a reasoning block outgrew the send-anchor's fill slack and then
collapsed at answer start, the spacer re-inflated and re-anchored the
sent message — visibly scrolling the reply the reader was following
(~267px measured in the harness). This was the biggest reported shift
left after #2423, previously attempted in #2426 (reverted).

The spacer is now one-way while a response streams: on pure content
resizes it only shrinks (a collapse rides the browser clamp instead,
keeping everything below it viewport-stable), while turn starts,
container resizes (mobile keyboard close, window resize, panel toggle)
and settled conversations still recompute freely — so a post-stream
collapse (manual toggle, think-only reply) is absorbed by re-inflation
with constant scrollHeight, and the anchor is never frozen at a stale
height when the viewport itself changes (the gap in the reverted
attempt).

Supporting controller fix: when a resize pass finds scrollTop already
clamp-jumped to max (content shrank / container grew at the bottom),
the follow re-pins instantly in the same frame instead of springing.
The clamp paints nowhere (pre-paint restore) and the coalesced scroll
event matches our write; a spring here both showed the bounce and —
once the spacer re-inflation erased the clamp's max<lastMax signature —
had the clamp's scroll event misread as user input, unpinning
mid-stream. The snap fires ONLY on that clamp signature: an eager
write on any resize would overwrite a user scroll pending in the same
pass (ResizeObserver delivery can precede the scroll event) and then
swallow their coalesced event as ours.

Regression tests: mid-stream collapse keeps content below stable;
post-stream collapse is zero-motion via re-inflation; container growth
mid-turn re-derives the anchor (spacer grows, message returns to the
anchor offset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013T9Ld83jc8eegpSf39wgyD

* Manual scroll anchoring for engines without overflow-anchor (Safari)

Safari has no native scroll anchoring, so any above-viewport shrink — a
collapsing thinking block, a late image, a markdown swap — shoved a
detached reader's text by the full height delta (~350px measured; 0px
on Chrome, whose native anchoring compensates). Re-lands the manual
anchoring from #2426 (reverted): while detached, track the element at
the viewport top (binary search over message wrappers, then a descent
to the deepest straddling descendant, stored as an ancestor chain so
markdown re-renders that replace the deep node fall back to a surviving
ancestor) and restore its position after content resizes through an
attribution-safe adjustBy on the controller. Feature-detected via
CSS.supports("overflow-anchor", "auto"): Chrome keeps native anchoring
and never attaches the listener.

New since the reverted attempt: compensation is skipped when scrollTop
moved since the anchor was captured. Resize passes are not ordered
after pending scroll events (observed empirically in the harness), so
the captured offsets can include a user scroll that hasn't been
classified yet — "restoring" from that stale capture cancels the
user's movement, i.e. the view fights the finger while reading during
a stream. One uncompensated frame is invisible; the fight is not. This
is the most plausible field failure mode of the original attempt.

Tests: simulated Safari (overflow-anchor forced off) — above-viewport
collapse keeps a detached reader stable; in-message descendant
tracking; anchored-node replacement falls back and re-resolves; and a
user scroll landing in the same pass as a shrink is never cancelled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013T9Ld83jc8eegpSf39wgyD

* Animate the thinking-block collapse instead of teleporting it

At answer start the reasoning block collapsed ~300px in a single frame,
and for the common lone-thinking reply it could not even be animated:
ChatMessage rendered the streaming and settled phases from two separate
template branches, so the flip destroyed the expanded component and
mounted a fresh collapsed one.

ChatMessage now renders one renderUnits template for both phases. The
streaming behavior is intentionally unchanged from before — while the
process phase streams, every group still renders its blocks flat and
inline (unlike the reverted #2426, which summarized earlier runs
mid-stream) — but the lone thinking block goes through the same branch
in both phases, so its instance survives the flip and its
expand/collapse is a height slide (220ms cubicOut, instant under
prefers-reduced-motion). With the one-way spacer, the slide's shrink is
absorbed frame-by-frame by the follow instead of landing as one clamp.

Two subtleties carried from the reverted attempt: the open/collapse
tracker runs pre-render ($effect.pre) so the slide-out starts from the
still-capped streaming viewport rather than a one-frame bounce to the
full settled height, and the inner branch keeps the capped shape
whenever the block is closing so a mid-outro re-render cannot grow the
collapsing box. Regression test asserts the settled prose never appears
during the collapse and the box never exceeds its streaming height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013T9Ld83jc8eegpSf39wgyD

* Add project verify skill: drive chat-ui against a mock streaming backend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013T9Ld83jc8eegpSf39wgyD

---------

Co-authored-by: Claude <noreply@anthropic.com>
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