Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
a5ff28be93 fix(desktop): stop at-rest scroll jump-up during code-block highlight
Follow-up to #38221. Users still saw the chat viewport jump up then snap
back while reading at the bottom — pinned by one reporter to code/patch
blocks being highlighted. PR #38221 fixed the disarm (scrolled-up) path;
this is the armed-at-bottom path.

Root cause, verified live in headless Chromium (CDP), NOT just modeled:
while parked at bottom, Streamdown/Shiki re-tokenizing a code block briefly
REPLACES laid-out DOM, so for one frame the content is SHORTER. The browser
clamps scrollTop upward the instant content shrinks below the scroll
position (1400 -> 1100 with NO pin involved); the next frame content
regrows and the rAF pin snaps it back down. A pin cannot prevent the
up-jump because the clamp happens at layout, before any pin runs (confirmed:
both deferred and synchronous re-pins still show 1400 -> 1100 -> 1480).

Fix: keep the content's height MONOTONIC within a turn. The ResizeObserver
raises a high-water-mark and reserves it as min-height on the content BEFORE
pinning, so a transient shrink never shrinks the scroller and the browser
never clamps. scrollHeight still grows under the viewport so streaming
tokens follow. Reset the high-water-mark in jumpToBottom (new turn / session
/ first content) and on user disarm so an old tall thread can't pad a new
short one and a finished turn can't leave a dead gap.

Live CDP proof (real Chromium native clamping):
  current main:  parked 1400 -> shrink 1100 -> grow 1480   (bounce)
  this fix:      parked 1400 -> shrink 1400 -> grow 1400    (no bounce)

Adds a streaming.test.tsx regression that models the browser clamp-on-shrink
(scrollHeight = max(measured, reserved min-height); scrollTop clamps on
shrink). Armed at bottom, a shrink RO frame must keep scrollTop at 1400 and
reserve min-height 2000px. RED on pre-fix main (min-height stays empty).
2026-06-03 09:36:22 -07:00
2 changed files with 131 additions and 4 deletions

View File

@@ -449,6 +449,77 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('holds the viewport at bottom when content transiently shrinks while armed', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
// Model a real browser: the scroller's scrollHeight is the LARGER of the
// measured content height and any reserved min-height the hook pins on the
// content; scrollTop clamps into [0, scrollHeight - clientHeight] whenever
// scrollHeight changes (this clamp-on-shrink is what yanked the viewport up
// in headless Chromium, independent of any pin).
let measured = 2_000
const minHeightOf = () => {
const raw = content.style.minHeight
return raw.endsWith('px') ? Number.parseFloat(raw) : 0
}
const effectiveHeight = () => Math.max(measured, minHeightOf())
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 600 })
// Both the content box and the scroller honor the reserved min-height, so
// their measured height is max(real content, min-height) — exactly how a
// real browser sizes a block with `min-height` set. The hook reads
// content.scrollHeight to build its high-water-mark.
Object.defineProperty(content, 'scrollHeight', {
configurable: true,
get: effectiveHeight
})
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: effectiveHeight
})
let rawTop = 0
const clamp = () => {
const max = Math.max(0, effectiveHeight() - 600)
rawTop = Math.max(0, Math.min(rawTop, max))
}
Object.defineProperty(viewport, 'scrollTop', {
configurable: true,
get: () => rawTop,
set: (value: number) => {
rawTop = value
clamp()
}
})
// Park armed at the bottom; the initial RO pass records the high-water-mark.
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(2_000)
}
})
await wait(0)
viewport.scrollTop = viewport.scrollHeight
await wait(0)
expect(viewport.scrollTop).toBe(1_400)
// A code block gets highlighted: measured content shrinks one frame. The RO
// fires; the hook must reserve min-height = high-water-mark FIRST so the
// scroller does not shrink and the browser never clamps scrollTop upward.
measured = 1_700
await act(async () => {
clamp() // browser would clamp here if scrollHeight actually dropped
for (const observer of resizeObservers) {
observer.trigger(1_700)
}
})
await wait(0)
expect(content.style.minHeight).toBe('2000px')
expect(viewport.scrollTop).toBe(1_400)
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)

View File

@@ -195,6 +195,21 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
const programmaticScrollPendingRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
// High-water-mark of the content's scrollHeight within the current turn.
// While parked at the bottom, syntax-highlighting a code/patch block
// (Streamdown/Shiki) briefly REPLACES laid-out DOM, so for one frame the
// content is SHORTER than steady state. The browser clamps scrollTop up the
// instant content shrinks below the scroll position (verified in headless
// Chromium: 1400 → 1100 with no pin involved), and the next frame's regrow
// pin snaps it back down — the at-rest jump-up-then-return bounce users
// still saw after the disarm fixes (#38221 follow-up). A pin cannot prevent
// it: the clamp happens at layout, before any rAF pin runs. The cure is to
// keep the content's height MONOTONIC within a turn by pinning a
// `min-height` to the high-water-mark, so a transient shrink never shrinks
// the scroller and the browser never clamps. Reset per turn/session in
// `jumpToBottom` (and cleared on disarm) so an old tall thread can't pad a
// new short one.
const contentHwmRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
@@ -213,6 +228,17 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
const jumpToBottom = useCallback(() => {
armedRef.current = true
// New turn / session / first content: re-establish bottom from scratch, so
// drop any reserved min-height from the previous turn (an old tall thread
// must not pad a new short one). The RO will rebuild the high-water-mark
// from this turn's real content.
const content = scrollerRef.current?.firstElementChild as HTMLElement | null
if (content) {
content.style.minHeight = ''
}
contentHwmRef.current = 0
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
@@ -222,7 +248,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
pinToBottom()
}
})
}, [groupCount, pinToBottom, virtualizer])
}, [groupCount, pinToBottom, scrollerRef, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
@@ -238,6 +264,17 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
const disarm = () => {
armedRef.current = false
programmaticScrollPendingRef.current = 0
// Release the reserved min-height when the user takes over scrolling, so
// a finished turn's high-water-mark padding doesn't leave a dead gap at
// the bottom of a thread they're reading from the middle. It's rebuilt
// on the next jumpToBottom (return to bottom / new turn).
const content = el.firstElementChild as HTMLElement | null
if (content) {
content.style.minHeight = ''
}
contentHwmRef.current = 0
}
const onScroll = () => {
@@ -340,13 +377,32 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
})
}
const observer = new ResizeObserver(schedulePin)
const content = el.firstElementChild as HTMLElement | null
const observer = new ResizeObserver(() => {
// Keep content height monotonic within a turn while armed. Raise the
// high-water-mark and reserve it as `min-height` BEFORE scheduling the
// pin, so a transient shrink (Shiki re-tokenizing a code block) can't
// shrink the scroller and make the browser clamp scrollTop upward. We
// only ever GROW the reserved height here; it's reset per turn/session
// in jumpToBottom and cleared on user disarm.
if (content && armedRef.current) {
const measured = content.scrollHeight
if (measured > contentHwmRef.current) {
contentHwmRef.current = measured
content.style.minHeight = `${measured}px`
}
}
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)
if (content) {
observer.observe(content)
}
return () => observer.disconnect()