From e029b7597bdfa8d0445e6e59584386363e4c5a55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:29:32 -0700 Subject: [PATCH] feat(desktop): stop the chat viewport from following streaming output (#41414) The desktop chat GUI pinned the viewport to the bottom on every content growth while a turn streamed, so the window chased tokens as they arrived. Remove that follow behavior: once a turn is running the viewport stays exactly where the user left it. - Delete the streaming ResizeObserver re-pin loop in useThreadScrollAnchor. - Delete the post-run bottom lock (kept pinning ~1.2s after completion). - Keep the one-time jump-to-bottom on user submit / new turn / session change so a freshly submitted message still lands in view. - Update streaming.test.tsx to assert the viewport no longer follows streaming growth or snaps down on final code-highlight remeasure. --- .../assistant-ui/streaming.test.tsx | 15 ++- .../assistant-ui/thread-virtualizer.tsx | 120 ++++-------------- 2 files changed, 38 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index 2c4095eb74..c15b4696a2 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -489,7 +489,7 @@ describe('assistant-ui streaming renderer', () => { expect(viewport.scrollTop).toBe(420) }) - it('keeps sticky-bottom armed through viewport height changes during streaming', async () => { + it('does not follow streaming content growth even while parked at the bottom', async () => { const { container } = render() const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement @@ -508,6 +508,7 @@ describe('assistant-ui streaming renderer', () => { await wait(80) + // Park the user at the bottom of the current content. await act(async () => { viewport.scrollTop = 800 fireEvent.scroll(viewport) @@ -520,6 +521,9 @@ describe('assistant-ui streaming renderer', () => { fireEvent.scroll(viewport) }) + // Content grows as tokens stream in. Streaming auto-follow is removed, so + // the viewport must NOT chase the new bottom — it stays where the user + // last left it. scrollHeight = 1_200 await act(async () => { @@ -529,7 +533,7 @@ describe('assistant-ui streaming renderer', () => { }) await wait(0) - expect(viewport.scrollTop).toBe(1_200) + expect(viewport.scrollTop).toBe(760) }) it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => { @@ -566,7 +570,7 @@ describe('assistant-ui streaming renderer', () => { expect(viewport.scrollTop).toBe(420) }) - it('keeps following final code-highlight growth when a run completes at bottom', async () => { + it('does not snap to the bottom on final code-highlight growth after a run completes', async () => { const { container } = render() const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement @@ -588,10 +592,13 @@ describe('assistant-ui streaming renderer', () => { await wait(650) + // Completion re-measures (Shiki highlight) and grows the content. The + // post-run bottom lock is removed, so the viewport stays put instead of + // snapping to the new bottom. scrollHeight = 1_700 await wait(0) - expect(viewport.scrollTop).toBe(1_700) + expect(viewport.scrollTop).toBe(800) }) it('does not restart bottom-follow after completion when the user scrolled up', async () => { diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 7922c3870d..e0c6df4293 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -19,7 +19,6 @@ import { setThreadScrolledUp } from '@/store/thread-scroll' const ESTIMATED_ITEM_HEIGHT = 220 const OVERSCAN = 4 const AT_BOTTOM_THRESHOLD = 4 -const POST_RUN_BOTTOM_LOCK_MS = 1_200 type ThreadMessageComponents = ComponentProps['components'] @@ -369,51 +368,15 @@ function useThreadScrollAnchor({ } }, [scrollerRef, stickyBottomRef]) - // Follow content growth (streaming, item measurements, loading indicator) - // while armed. During fast streaming the ResizeObserver can fire many - // times per frame as Streamdown re-tokenizes; coalesce to one pin per - // animation frame so we don't run the scroll-event/re-pin chain - // (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per - // token. - useEffect(() => { - if (!enabled || !isRunning) { - return undefined - } - - const el = scrollerRef.current - - if (!el) { - return undefined - } - - let pinRafScheduled = false - - const schedulePin = () => { - if (pinRafScheduled || !stickyBottomRef.current) { - return - } - - pinRafScheduled = true - requestAnimationFrame(() => { - pinRafScheduled = false - - if (stickyBottomRef.current) { - pinToBottom() - } - }) - } - - const observer = new ResizeObserver(schedulePin) - - // Observe ONLY the content (firstElementChild), not the scroller `el` - // itself. Resizes of the viewport/scroller (window resize, devtools - // panel toggle) shouldn't trigger a pin — only content growth should. - if (el.firstElementChild) { - observer.observe(el.firstElementChild) - } - - return () => observer.disconnect() - }, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef]) + // Intentionally NO streaming auto-follow. Earlier builds ran a + // ResizeObserver here that re-pinned the viewport to the bottom on every + // content growth while a turn was running, so the chat tracked tokens as + // they streamed. That behavior is removed by request: once a turn is in + // flight the viewport stays exactly where the user left it. The viewport + // is still moved to the bottom ONCE per user submit / new turn / session + // change (see the layout effect and the session-change effect below) so a + // freshly submitted message lands in view — but it does not chase the + // stream afterward. // Jump to bottom on session change OR when an empty thread first gets // content. Both share the same intent and the same effect. @@ -429,22 +392,21 @@ function useThreadScrollAnchor({ } }, [enabled, groupCount, jumpToBottom, sessionKey]) - // Pre-paint pin: when groupCount increases while armed (optimistic user - // message insert, streaming assistant turn arriving, etc.), pin BEFORE - // the browser commits the layout to screen. Using useLayoutEffect rather - // than useEffect so this runs synchronously after React commits the DOM - // mutation but before the browser paints. Without this, there's a ~50ms - // visual window where the new message sits below the fold while we wait - // for the ResizeObserver / scroll event chain to fire and re-pin. + // Pre-paint pin: when groupCount increases while armed (a new turn arriving + // from the user submit or assistant turn start), pin BEFORE the browser + // commits the layout to screen. Using useLayoutEffect rather than useEffect + // so this runs synchronously after React commits the DOM mutation but before + // the browser paints. Without this, there's a ~50ms visual window where the + // new message sits below the fold. // // We pin TWICE in this critical path — once synchronously, then once on // the next rAF. The second pin catches the case where React mounts the // new message in the second commit (after our layout effect ran), which // grows scrollHeight again; without the rAF pin the user briefly sees a - // ~15 px gap below the new message until the RO catches up. Streaming - // tokens use the rate-limited RO path only; only the group-count change - // (which fires once per user submit / new turn arrival) pays for the - // extra pin. + // ~15 px gap below the new message. This fires once per user submit / new + // turn arrival — it is NOT streaming-token follow (that path is removed + // above), so a turn that streams a long response after this initial jump + // will not chase the bottom. const prevGroupCountForLayoutRef = useRef(groupCount) useLayoutEffect(() => { if (!enabled) { @@ -468,45 +430,17 @@ function useThreadScrollAnchor({ prevGroupCountForLayoutRef.current = groupCount }, [enabled, groupCount, pinToBottom, stickyBottomRef]) - // Completion swaps streaming placeholders/plain code for final rendered DOM - // (notably Shiki-highlighted code). Keep following the bottom briefly after - // `isRunning` flips false so that final measurement pass cannot strand the - // viewport near the top of a large code block. + // Intentionally NO post-run bottom lock. Earlier builds kept pinning to + // the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to + // chase final Shiki re-highlight measurement. With streaming follow gone, + // re-pinning at completion would yank the viewport back to the bottom even + // though the user is reading earlier content — the opposite of what's + // wanted. The one-time submit / new-turn jump already covers landing a + // fresh message in view. const prevIsRunningForLayoutRef = useRef(isRunning) useLayoutEffect(() => { - const finishedRun = prevIsRunningForLayoutRef.current && !isRunning prevIsRunningForLayoutRef.current = isRunning - - if (!enabled || !finishedRun || !stickyBottomRef.current) { - return undefined - } - - const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS - let lockRaf: number | null = null - - const lockFrame = () => { - lockRaf = null - - if (!stickyBottomRef.current) { - return - } - - pinToBottom() - - if (performance.now() < lockUntil) { - lockRaf = requestAnimationFrame(lockFrame) - } - } - - pinToBottom() - lockRaf = requestAnimationFrame(lockFrame) - - return () => { - if (lockRaf !== null) { - cancelAnimationFrame(lockRaf) - } - } - }, [enabled, isRunning, pinToBottom, stickyBottomRef]) + }, [isRunning]) useAuiEvent('thread.runStart', jumpToBottom) }