From ac8246e97c029f61ce33858fe18bf14844d34663 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 06:41:42 +0000 Subject: [PATCH] Fix chat scroll jitter during streaming; respect reduced motion 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) --- packages/app/src/pages/session.tsx | 11 ++++++++++- .../app/src/pages/session/message-timeline.tsx | 15 ++++++++++++--- packages/ui/src/components/message-part.tsx | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dda77aaff..f5949620a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1200,8 +1200,17 @@ export default function Page() { ), ) + // Only follow the bottom while the model is actually streaming. This was + // previously hard-coded to `true`, so the auto-scroller's ResizeObserver + // force-scrolled on *any* reflow (image load, tool accordion expand, font + // swap, layout settle) — which read as the chat "jumping" on its own. Gating + // it on the session's real working state also stops it from competing with + // the timeline's own bottom-lock outside of streaming. const autoScroll = createAutoScroll({ - working: () => true, + working: () => { + const id = params.id + return id ? sync.data.session_working(id) : false + }, overflowAnchor: "dynamic", }) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c6329b39c..fa2325a93 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -568,12 +568,21 @@ export function MessageTimeline(props: { createEffect( on( () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, - () => { + (curr, prev) => { if (!virtualizer) return if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return - const keys = timelineRowKeys() + const keys = curr[0] if (keys.length === 0) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) + // Only realign via virtua (an estimate-based scroll that can visibly + // pre-jump before item heights are measured) when the *set of rows* + // changes or the session status flips. Pure intra-row growth while a + // token streams is left to the measured-bottom rAF lock below, which + // pins against the real DOM height — so the two mechanisms stop landing + // on slightly different scroll positions frame-to-frame. `timelineRowKeys` + // is memoized with `equals: sameKeys`, so its reference only changes when + // the row set actually changes, making this comparison cheap and exact. + const rowsChanged = !prev || prev[0] !== keys || prev[2] !== curr[2] + if (rowsChanged) virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) scheduleMeasuredBottomAnchor() }, { defer: true }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 71b1fa807..cd4d42705 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -60,6 +60,9 @@ import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +const reducedMotion = () => + typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + async function writeClipboard(text: string): Promise { const body = typeof document === "undefined" ? undefined : document.body if (body) { @@ -90,6 +93,17 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) { onMount(() => { if (!props.animate) return + // The initial render collapses width to 0 and hides the value behind a blur; + // if the user prefers reduced motion, snap straight to the resting state + // instead of animating (and instead of leaving it stuck collapsed/hidden). + if (reducedMotion()) { + if (widthRef) widthRef.style.width = "auto" + if (valueRef) { + valueRef.style.opacity = "1" + valueRef.style.filter = "blur(0px)" + } + return + } requestAnimationFrame(() => { if (widthRef) { animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })