Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️ Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.

- session.tsx: gate createAutoScroll on the session's real working state
  instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
  force-following the bottom on *any* reflow (image load, accordion expand,
  font swap), which read as the chat jumping on its own. Send-to-bottom and
  the jump button are unaffected (they use the force path).

- message-timeline.tsx: only realign via virtua's estimate-based
  scrollToIndex(align:"end") when the row set changes or status flips, not
  on every streamed token. Pure intra-row growth is left to the
  measured-bottom rAF lock, which pins against the real DOM height, so the
  two mechanisms stop disagreeing frame-to-frame. Safe because
  timelineRowKeys is memoized with `equals: sameKeys`.

- message-part.tsx: honor prefers-reduced-motion in the imperative
  ShellSubmessage reveal (the CSS already does; this JS animate() did not,
  and its initial render collapses width to 0 / blurs the value).

Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicode Jul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)

Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.

- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
  (contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)

Closes #115
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