From c376c94a79a2ae9db95933993bad6991a89fe54a Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:47:38 +0000 Subject: [PATCH] fix(web): stop the transcript scrolling itself on every streamed chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat transcript follows the live edge while an agent streams, which fires a scroll once per chunk. Over a long session that continuous movement is tiring to read, and there was no way to turn it off. Adds two per-device settings. `chatAutoScroll` gates live-follow and defaults to off: the transcript holds still, the send-time anchor still positions the new turn once, and the scroll-to-end pill becomes the way back. `reduceMotion` forces the reduced-motion path on regardless of the OS preference, making scrolls instant and stopping the looping indicator animations that otherwise never stop repainting while an agent works. The pill's visibility guard needed care rather than a simple gate. Leaving the live edge without a user gesture means a programmatic scroll is still settling, not that the reader moved: with follow on, live-follow owns the position for as long as it holds it; with it off, only the window just after a thread switch or a send is settling, because LegendList reports not-at-end while initialScrollAtEnd lands. Suppressing indefinitely in that mode would hide the pill exactly when it is the only way back, and not suppressing at all would flash it on every thread switch. Reduce motion also covers the provider-update indicator, whose arbitrary [animation:bounce_...] class the animate-* selectors never matched and whose motion-reduce: variant only answers to the OS preference. Spinners are deliberately left spinning — a frozen spinner reads as a hung app. Written by Claude Opus 4.5 in Claude Code. --- apps/web/src/components/ChatView.tsx | 60 ++++++++++++++++--- .../components/chat/MessagesTimeline.test.tsx | 23 +++++-- .../src/components/chat/MessagesTimeline.tsx | 35 ++++++----- .../components/chat/draftHeroTransition.ts | 8 ++- .../components/settings/SettingsPanels.tsx | 52 ++++++++++++++++ apps/web/src/index.css | 43 +++++++++++++ apps/web/src/reducedMotion.ts | 24 ++++++++ apps/web/src/routes/__root.tsx | 6 ++ docs/README.md | 1 + docs/user/reduced-motion.md | 34 +++++++++++ packages/contracts/src/settings.ts | 9 +++ 11 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/reducedMotion.ts create mode 100644 docs/user/reduced-motion.md diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d532c8b1233b..0084a23f9425 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -166,7 +166,8 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { getClientSettings, useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { prefersReducedMotion } from "../reducedMotion"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -307,6 +308,12 @@ import { } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +// How long after live-follow takes the scroll position we still treat a +// not-at-end report as that scroll settling rather than the reader moving. Only +// consulted when follow-output is off; while it is on, live-follow owns the +// position for as long as it holds it. +const LIVE_FOLLOW_SETTLE_MS = 500; + const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; @@ -333,9 +340,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; const stateChanged = previousStateRef.current !== isDraftHeroState; - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const reducedMotion = prefersReducedMotion(getClientSettings().reduceMotion); const mobileComposerTransitionActive = typeof document !== "undefined" && document.documentElement.dataset.mobileComposerRouteTransition === "true"; @@ -346,7 +351,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const previousComposerRect = previousComposerRectRef.current; if ( stateChanged && - !prefersReducedMotion && + !reducedMotion && !mobileComposerTransitionActive && transitionGroup && previousComposerRect && @@ -3435,6 +3440,21 @@ function ChatViewContent(props: ChatViewProps) { const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); + const chatAutoScroll = useClientSettings((settings) => settings.chatAutoScroll); + const reduceMotionSetting = useClientSettings((settings) => settings.reduceMotion); + const timelineReduceMotion = prefersReducedMotion(reduceMotionSetting); + // The scroll callbacks below are deliberately stable (empty dependency lists, + // refs only) so changing a setting never re-creates them mid-stream; these + // mirrors are what let those callbacks read the current values. + const chatAutoScrollRef = useRef(chatAutoScroll); + // When live-follow last took ownership of the scroll position, used to tell a + // settling programmatic scroll apart from the reader falling behind. + const liveFollowSyncedAtRef = useRef(0); + const timelineReduceMotionRef = useRef(timelineReduceMotion); + useEffect(() => { + chatAutoScrollRef.current = chatAutoScroll; + timelineReduceMotionRef.current = timelineReduceMotion; + }, [chatAutoScroll, timelineReduceMotion]); const timelineScrollModeRef = useRef("following-end"); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); @@ -3523,6 +3543,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowSyncedAtRef.current = performance.now(); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3603,7 +3624,7 @@ function ChatViewContent(props: ChatViewProps) { scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); void list.scrollToIndex({ index: anchorIndex, - animated: true, + animated: !timelineReduceMotionRef.current, viewPosition: 0, viewOffset: CHAT_LIST_ANCHOR_OFFSET, }); @@ -3654,8 +3675,18 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + // Leaving the live edge without a user gesture means a programmatic scroll + // is still settling, not that the reader navigated away, so the pill stays + // hidden. While live-follow is on it owns the position indefinitely. With it + // off, only the window right after a thread switch or a send is settling — + // LegendList reports not-at-end while `initialScrollAtEnd` lands — and once + // that passes, falling behind is exactly when the pill has to appear. + const settling = + chatAutoScrollRef.current || + performance.now() - liveFollowSyncedAtRef.current < LIVE_FOLLOW_SETTLE_MS; if ( !isAtEnd && + settling && liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current ) { showScrollDebouncer.current.cancel(); @@ -3667,6 +3698,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowSyncedAtRef.current = performance.now(); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3676,10 +3708,18 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + // Live-follow. This runs on every timeline change, i.e. once per streamed + // chunk, so it is the scrolling a reader actually feels. With the setting off + // the transcript holds still and the scroll-to-end pill takes over; the + // one-time anchor positioning after a send is unaffected because that lives in + // onTimelineAnchorReady. useEffect(() => { if (!activeThread?.id) { return; } + if (!chatAutoScroll) { + return; + } if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } @@ -3737,6 +3777,7 @@ function ChatViewContent(props: ChatViewProps) { }; }, [ activeThread?.id, + chatAutoScroll, timelineEntries, getActiveTimelineTurnMetrics, timelineRealContentOverflowsViewport, @@ -3747,6 +3788,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowSyncedAtRef.current = performance.now(); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -4665,6 +4707,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowSyncedAtRef.current = performance.now(); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5108,6 +5151,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowSyncedAtRef.current = performance.now(); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5758,6 +5802,8 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} + autoScrollEnabled={chatAutoScroll} + reduceMotion={timelineReduceMotion} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5770,7 +5816,7 @@ function ChatViewContent(props: ChatViewProps) { type="button" aria-label="Scroll to end" title="Scroll to end" - onClick={() => scrollToEnd(true)} + onClick={() => scrollToEnd(!timelineReduceMotion)} className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" > diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e9527..c20c8eb8bdd7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -414,16 +414,29 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain("Show full message"); - expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); - expect(markup).toContain('data-maintain-scroll-at-end-animated="false"'); - expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"'); - expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"'); - expect(markup).toContain('data-maintain-scroll-at-end-layout="true"'); expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-fade="true"'); expect(markup).toContain('data-user-message-footer="true"'); }); + it("only lets the list re-pin itself to the end when auto-scroll is on", () => { + const timelineEntries = [buildUserTimelineEntry("Short prompt.")]; + + const following = renderToStaticMarkup( + , + ); + expect(following).toContain('data-maintain-scroll-at-end="enabled"'); + expect(following).toContain('data-maintain-scroll-at-end-animated="false"'); + expect(following).toContain('data-maintain-scroll-at-end-data-change="true"'); + expect(following).toContain('data-maintain-scroll-at-end-item-layout="true"'); + expect(following).toContain('data-maintain-scroll-at-end-layout="true"'); + + const still = renderToStaticMarkup( + , + ); + expect(still).not.toContain('data-maintain-scroll-at-end="enabled"'); + }); + it("does not render collapse controls for short user messages", () => { const markup = renderToStaticMarkup( void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + /** Whether the list may re-pin itself to the end as rows arrive or resize. */ + autoScrollEnabled?: boolean; + /** Turns the minimap jump into an instant scroll instead of an animated one. */ + reduceMotion?: boolean; } // --------------------------------------------------------------------------- @@ -219,6 +223,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + autoScrollEnabled = false, + reduceMotion = false, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -495,7 +501,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !autoScrollEnabled ? false : { animated: false, @@ -528,7 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation(); void listRef.current?.scrollToIndex({ index: item.rowIndex, - animated: true, + animated: !reduceMotion, viewOffset: 24, }); }} @@ -694,7 +700,7 @@ function TimelineMinimap({ return (