mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 08:00:17 +08:00
Compare commits
1 Commits
austin/fix
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ff28be93 |
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user