fix(desktop): defer parked scroll restore until ready

This commit is contained in:
Victor Nogueira
2026-08-27 16:40:18 +08:00
committed by Teknium
parent c06caad8f7
commit 0237fcc295
2 changed files with 263 additions and 3 deletions
@@ -0,0 +1,259 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setActiveProfile } from '@/store/profile'
import { getThreadScrollPosition, saveThreadScrollPosition, THREAD_SCROLL_BOTTOM } from '@/store/thread-scroll'
import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils'
import { Thread } from '.'
// A ResizeObserver that stores instances so the test can verify the wiring.
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
trigger(_height: number) {
if (!this.target) {
return
}
this.callback(
[
{
contentRect: { height: Number(_height) } as DOMRectReadOnly,
target: this.target
} as ResizeObserverEntry
],
this as unknown as ResizeObserver
)
}
}
stubThreadEnvironment()
// stubThreadEnvironment internally calls stubResizeObserver() which installs
// an InertResizeObserver. Re-apply our test double so the component's own
// ResizeObserver on the content element is exercised.
vi.stubGlobal('ResizeObserver', TestResizeObserver)
stubThreadViewportSize()
const SCROLL_H = 5000
const CLIENT_H = 600
let scrollHeightValue = SCROLL_H
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get() {
return scrollHeightValue
}
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return CLIENT_H
}
})
beforeEach(() => {
resizeObservers.clear()
scrollHeightValue = SCROLL_H
window.localStorage.clear()
setActiveProfile('default')
})
afterEach(() => {
resizeObservers.clear()
})
const VIEWPORT_SLOT = 'aui_thread-viewport'
function viewportEl(container: HTMLElement): HTMLElement {
const el = container.querySelector(`[data-slot="${VIEWPORT_SLOT}"]`) as HTMLElement | null
expect(el).toBeTruthy()
return el!
}
async function settleScroll(ticks = 3) {
await act(async () => {
for (let tick = 0; tick < ticks; tick += 1) {
await new Promise<void>(resolve => window.setTimeout(resolve, 0))
}
})
}
const createdAt = new Date('2026-08-01T00:00:00.000Z')
function sessionMessages(key: string, turns = 1): ThreadMessage[] {
return Array.from({ length: turns }, (_, index) => [
{
id: `u-${key}-${index}`,
role: 'user',
content: [{ type: 'text', text: `message ${index} in ${key}` }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage,
{
id: `a-${key}-${index}`,
role: 'assistant',
content: [{ type: 'text', text: `response ${index} in ${key}` }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as ThreadMessage
]).flat()
}
interface ScrollHarnessProps {
messages: ThreadMessage[]
sessionKey: string | null
}
function ScrollHarness({ messages, sessionKey }: ScrollHarnessProps) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
isRunning: false,
messages,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread sessionKey={sessionKey} />
</AssistantRuntimeProvider>
)
}
describe('list session-scroll restore', () => {
it('pins to the bottom for an unknown session', async () => {
const { container } = render(<ScrollHarness messages={sessionMessages('a')} sessionKey="a" />)
const vp = viewportEl(container)
await settleScroll()
expect(vp.scrollTop).toBe(SCROLL_H - CLIENT_H)
expect(getThreadScrollPosition('a')).toBeUndefined()
})
it('pins to the bottom for a warm session already saved as bottom', async () => {
saveThreadScrollPosition('a', THREAD_SCROLL_BOTTOM)
const { container } = render(<ScrollHarness messages={sessionMessages('a')} sessionKey="a" />)
const vp = viewportEl(container)
await settleScroll()
expect(vp.scrollTop).toBe(SCROLL_H - CLIENT_H)
})
it('restores a reading offset on return after switching away', async () => {
saveThreadScrollPosition('a', { fromBottom: 800, kind: 'offset' })
const { container, rerender } = render(<ScrollHarness messages={sessionMessages('a')} sessionKey="a" />)
const vp = viewportEl(container)
await settleScroll()
expect(vp.scrollTop).toBe(SCROLL_H - 800 - CLIENT_H)
rerender(<ScrollHarness messages={sessionMessages('b')} sessionKey="b" />)
const vpB = viewportEl(container)
await settleScroll()
expect(vpB.scrollTop).toBe(SCROLL_H - CLIENT_H)
rerender(<ScrollHarness messages={sessionMessages('a')} sessionKey="a" />)
const vpA = viewportEl(container)
await settleScroll()
expect(vpA.scrollTop).toBe(SCROLL_H - 800 - CLIENT_H)
})
it('waits for first content on a cold switch with empty transcript', async () => {
saveThreadScrollPosition('b', { fromBottom: 240, kind: 'offset' })
const { container, rerender } = render(<ScrollHarness messages={[]} sessionKey="b" />)
viewportEl(container)
await settleScroll()
rerender(<ScrollHarness messages={sessionMessages('b')} sessionKey="b" />)
const vp = viewportEl(container)
await settleScroll()
expect(vp.scrollTop).toBe(SCROLL_H - 240 - CLIENT_H)
})
it('keeps a clamped cold offset parked until the transcript is tall enough', async () => {
saveThreadScrollPosition('b', { fromBottom: 800, kind: 'offset' })
scrollHeightValue = CLIENT_H
const { container, rerender } = render(<ScrollHarness messages={[]} sessionKey="b" />)
const vp = viewportEl(container)
await settleScroll()
scrollHeightValue = 1000
rerender(<ScrollHarness messages={sessionMessages('b')} sessionKey="b" />)
await settleScroll(20)
expect(vp.scrollTop).toBe(0)
rerender(<ScrollHarness messages={sessionMessages('b', 2)} sessionKey="b" />)
await settleScroll()
expect(vp.scrollTop).toBe(0)
scrollHeightValue = 2000
rerender(<ScrollHarness messages={sessionMessages('b', 3)} sessionKey="b" />)
await settleScroll()
expect(vp.scrollTop).toBe(2000 - CLIENT_H - 800)
})
it('height-only relayout updates the scroll state without a scroll event', async () => {
const { container, rerender } = render(<ScrollHarness messages={sessionMessages('a')} sessionKey="a" />)
const vp = viewportEl(container)
await settleScroll()
expect(resizeObservers.size).toBeGreaterThanOrEqual(1)
vp.scrollTop = SCROLL_H - CLIENT_H - 350
scrollHeightValue = SCROLL_H + 200
for (const obs of resizeObservers) {
obs.trigger(scrollHeightValue)
}
rerender(<ScrollHarness messages={sessionMessages('b')} sessionKey="b" />)
viewportEl(container)
await act(async () => {
await new Promise<void>(resolve => window.setTimeout(resolve, 0))
})
const saved = getThreadScrollPosition('a')
expect(saved?.kind).toBe('offset')
expect(saved && saved.kind === 'offset' ? saved.fromBottom : 0).toBe(550)
})
})
@@ -853,7 +853,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// anchorBeforePrepend skips while unsettled, so the parked offset
// can't be overwritten by a mid-load anchor measurement. The restore
// effect flips settled once it consumes the parked value.
restoreFromBottomRef.current = target.fromBottom
restoreFromBottomRef.current = target.fromBottom + node.clientHeight
} else {
loadSettledRef.current = true
}
@@ -896,9 +896,10 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
useLayoutEffect(() => {
const el = scrollRef.current
const restoreFromBottom = restoreFromBottomRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
if (el && restoreFromBottom != null && el.scrollHeight >= restoreFromBottom) {
el.scrollTop = el.scrollHeight - restoreFromBottom
restoreFromBottomRef.current = null
// Consuming a parked offset (clamped-exit) means the view just landed at
// its real reading position — the load is settled from here on.