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.
This commit is contained in:
Teknium
2026-06-07 17:29:32 -07:00
committed by GitHub
parent 1c7ae46f0e
commit e029b7597b
2 changed files with 38 additions and 97 deletions
@@ -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(<StreamingHarness />)
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(<StreamingHarness />)
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 () => {
@@ -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<typeof ThreadPrimitive.MessageByIndex>['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)
}