diff --git a/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx b/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx new file mode 100644 index 0000000000..de399bc3d1 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx @@ -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() + +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(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({ + isRunning: false, + messages, + onNew: async () => {} + }) + + return ( + + + + ) +} + +describe('list session-scroll restore', () => { + it('pins to the bottom for an unknown session', async () => { + const { container } = render() + 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() + 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() + const vp = viewportEl(container) + + await settleScroll() + + expect(vp.scrollTop).toBe(SCROLL_H - 800 - CLIENT_H) + + rerender() + const vpB = viewportEl(container) + + await settleScroll() + + expect(vpB.scrollTop).toBe(SCROLL_H - CLIENT_H) + + rerender() + 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() + viewportEl(container) + + await settleScroll() + + rerender() + 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() + const vp = viewportEl(container) + + await settleScroll() + + scrollHeightValue = 1000 + rerender() + await settleScroll(20) + + expect(vp.scrollTop).toBe(0) + + rerender() + await settleScroll() + + expect(vp.scrollTop).toBe(0) + + scrollHeightValue = 2000 + rerender() + 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() + 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() + viewportEl(container) + + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + + const saved = getThreadScrollPosition('a') + expect(saved?.kind).toBe('offset') + expect(saved && saved.kind === 'offset' ? saved.fromBottom : 0).toBe(550) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 78122b2a1a..cc407eb467 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -853,7 +853,7 @@ const ThreadMessageListInner: FC = ({ // 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 = ({ 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.