diff --git a/apps/desktop/src/components/assistant-ui/thread/list-auto-show-earlier.test.tsx b/apps/desktop/src/components/assistant-ui/thread/list-auto-show-earlier.test.tsx new file mode 100644 index 0000000000..04e35c6a9a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/list-auto-show-earlier.test.tsx @@ -0,0 +1,125 @@ +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { act, render } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' + +import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils' + +import { Thread } from '.' + +stubThreadEnvironment() +stubThreadViewportSize() + +const SCROLL_H = 20000 +const CLIENT_H = 600 + +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => SCROLL_H +}) +Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => CLIENT_H +}) + +beforeEach(() => window.localStorage.clear()) + +async function settle(ticks = 20) { + await act(async () => { + for (let tick = 0; tick < ticks; tick += 1) { + await new Promise(resolve => window.setTimeout(resolve, 0)) + } + }) +} + +// Heavy enough that the transcript outgrows RENDER_BUDGET (600 units) and the +// list hides older turns behind "Show earlier" — the shape of a branched +// session that inherits a long parent history. +const heavyText = 'x'.repeat(5000) +const createdAt = new Date('2026-08-01T00:00:00.000Z') + +function heavyTranscript(turns: number): ThreadMessage[] { + return Array.from({ length: turns }, (_, index) => [ + { + id: `u-${index}`, + role: 'user', + content: [{ type: 'text', text: heavyText }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage, + { + id: `a-${index}`, + role: 'assistant', + content: [{ type: 'text', text: heavyText }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} } + } as ThreadMessage + ]).flat() +} + +function Harness({ messages }: { messages: ThreadMessage[] }) { + const runtime = useExternalStoreRuntime({ isRunning: false, messages, onNew: async () => {} }) + + return ( + + + + ) +} + +const mountedGroups = (container: HTMLElement) => container.querySelectorAll('[data-slot="aui_message-group"]').length + +async function settledGroupCount(container: HTMLElement): Promise { + let count = mountedGroups(container) + + for (let round = 0; round < 20; round += 1) { + await settle(10) + const next = mountedGroups(container) + + if (next === count) { + return count + } + + count = next + } + + return count +} + +describe('top-edge auto Show earlier', () => { + it('pages older turns in when the reader wheels up at the clamped top, not mid-transcript', async () => { + const turns = 60 + const { container } = render() + const viewport = container.querySelector('[data-slot="aui_thread-viewport"]') as HTMLElement + + // Let the stepped first-paint → full-page backfill finish; from here only + // Show earlier grows the DOM. + const windowed = await settledGroupCount(container) + + expect(windowed).toBeGreaterThan(0) + expect(windowed).toBeLessThan(turns) + + // Leave the bottom lock the way a reader does: a scroll-up event. + viewport.scrollTop = SCROLL_H - CLIENT_H + act(() => viewport.dispatchEvent(new Event('scroll'))) + viewport.scrollTop = 2000 + act(() => viewport.dispatchEvent(new Event('scroll'))) + await settle(5) + + act(() => viewport.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 }))) + await settle(5) + + expect(mountedGroups(container)).toBe(windowed) + + // At the clamped top the browser emits no further scroll events — only + // the wheel says "I want to read earlier". + viewport.scrollTop = 0 + act(() => viewport.dispatchEvent(new Event('scroll'))) + await settle(5) + act(() => viewport.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 }))) + await settle() + + expect(mountedGroups(container)).toBeGreaterThan(windowed) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index b24e6b9995..0ab0b3acfd 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -40,7 +40,7 @@ import { isSecondaryWindow } from '@/store/windows' import { MessageRenderBoundary } from '../message-render-boundary' -import { resolveShowEarlierAction, shouldAutoShowEarlier, TOP_EDGE_PX, useTranscriptWindow } from './transcript-window' +import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window' import { useMessagesBelow } from './use-messages-below' type ThreadMessageComponents = ComponentProps['components'] @@ -957,8 +957,8 @@ const ThreadMessageListInner: FC = ({ }, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable, paneBudget]) // Scroll/wheel at the top edge pages older turns through the same showEarlier - // path as the button. Wheel is required because browsers often omit `scroll` - // once scrollTop is already 0. Fail-open gates live in shouldAutoShowEarlier. + // path as the button. Wheel is required because browsers emit no `scroll` + // once scrollTop is already 0 — exactly where the reader who wants more is. useEffect(() => { const el = scrollRef.current @@ -968,26 +968,21 @@ const ThreadMessageListInner: FC = ({ const tryShowEarlier = (wheelDeltaY?: number) => { if ( - !shouldAutoShowEarlier({ + shouldAutoShowEarlier({ action: resolveShowEarlierAction(hiddenCount, olderAvailable), isAtBottom, loadSettled: loadSettledRef.current, restorePending: restoreFromBottomRef.current != null, scrollTop: el.scrollTop, - topEdgePx: TOP_EDGE_PX, wheelDeltaY }) ) { - return + showEarlier() } - - showEarlier() } const onScroll = () => tryShowEarlier() - const onWheel = (event: WheelEvent) => { - tryShowEarlier(event.deltaY) - } + const onWheel = (event: WheelEvent) => tryShowEarlier(event.deltaY) el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('wheel', onWheel, { passive: true }) diff --git a/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts b/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts index e3cad94021..a36d17cdf9 100644 --- a/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts @@ -7,67 +7,24 @@ const settledTop = { isAtBottom: false, loadSettled: true, restorePending: false, - scrollTop: 0, - topEdgePx: TOP_EDGE_PX + scrollTop: 0 } -describe('TOP_EDGE_PX', () => { - it('is a small top-edge slack, not a mid-scroll threshold', () => { - expect(TOP_EDGE_PX).toBeGreaterThanOrEqual(48) - expect(TOP_EDGE_PX).toBeLessThanOrEqual(64) - }) -}) - describe('shouldAutoShowEarlier', () => { - it('loads earlier when the reader is at the top edge with a DOM page to spend', () => { - expect(shouldAutoShowEarlier({ ...settledTop, action: 'dom' })).toBe(true) - }) - - it('loads earlier when the reader is at the top edge with a store window to expand', () => { - expect( - shouldAutoShowEarlier({ - ...settledTop, - action: resolveShowEarlierAction(0, true) - }) - ).toBe(true) - }) - - it('treats scrollTop at the edge pixel as top-edge reading intent', () => { + it('pages only for a settled reader at the top edge with older content to show', () => { + // Reading intent: a scroll into the top edge, or an upward wheel while + // already clamped there (browsers emit no scroll event at scrollTop 0). + expect(shouldAutoShowEarlier(settledTop)).toBe(true) expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: TOP_EDGE_PX })).toBe(true) - }) - - it('loads earlier on an upward wheel while already at the top edge', () => { expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: -40 })).toBe(true) - }) + expect(shouldAutoShowEarlier({ ...settledTop, action: resolveShowEarlierAction(0, true) })).toBe(true) - it('does not fire when resolveShowEarlierAction is a no-op', () => { - expect(shouldAutoShowEarlier({ ...settledTop, action: null })).toBe(false) - expect( - shouldAutoShowEarlier({ - ...settledTop, - action: resolveShowEarlierAction(0, false) - }) - ).toBe(false) - }) - - it('does not fire until the session load has settled', () => { + // Every gate that must keep a page from loading on its own. + expect(shouldAutoShowEarlier({ ...settledTop, action: resolveShowEarlierAction(0, false) })).toBe(false) expect(shouldAutoShowEarlier({ ...settledTop, loadSettled: false })).toBe(false) - }) - - it('does not fire while a prepend restore is still pending', () => { expect(shouldAutoShowEarlier({ ...settledTop, restorePending: true })).toBe(false) - }) - - it('does not fire while the reader is following the bottom', () => { expect(shouldAutoShowEarlier({ ...settledTop, isAtBottom: true })).toBe(false) - }) - - it('does not fire in the middle of the transcript', () => { expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: TOP_EDGE_PX + 1 })).toBe(false) - expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: 400 })).toBe(false) - }) - - it('does not treat a downward or zero wheel at the top as earlier-reading intent', () => { expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: 40 })).toBe(false) expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: 0 })).toBe(false) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx index b6e6676860..cfeb965f40 100644 --- a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx @@ -33,7 +33,11 @@ export function resolveShowEarlierAction(hiddenCount: number, olderAvailable: bo return olderAvailable ? 'window' : null } -/** Slack (px) treated as "already at the top edge" for auto Show-earlier. */ +/** + * Slack (px) within which a reader counts as "at the top edge". Wide enough + * that a wheel notch landing a few pixels short of 0 still pages; well under + * the RUN_START_SNAP-style thresholds so a mid-transcript reader never does. + */ export const TOP_EDGE_PX = 48 export interface ShouldAutoShowEarlierInput { @@ -42,16 +46,16 @@ export interface ShouldAutoShowEarlierInput { loadSettled: boolean restorePending: boolean scrollTop: number - topEdgePx?: number /** Present only for `wheel` events; omitted for `scroll`. */ wheelDeltaY?: number } /** - * Auto Show-earlier is the button's `showEarlier()` path, triggered when the - * reader is at the viewport top and older content exists. Fail-open: missing - * action, an unsettled load, a pending prepend restore, following the bottom, - * or a mid-transcript scroll must not page. + * Whether reading at the viewport top should page older turns through the + * same `showEarlier()` path as the button. An unsettled load, a prepend + * restore still pending, a reader following the bottom, or a mid-transcript + * scroll must never page on its own — each of those has scrollTop near 0 or + * changing for reasons that are not "I want to read earlier". */ export function shouldAutoShowEarlier({ action, @@ -59,20 +63,12 @@ export function shouldAutoShowEarlier({ loadSettled, restorePending, scrollTop, - topEdgePx = TOP_EDGE_PX, wheelDeltaY }: ShouldAutoShowEarlierInput): boolean { - if (action == null || !loadSettled || restorePending || isAtBottom) { + if (action == null || !loadSettled || restorePending || isAtBottom || scrollTop > TOP_EDGE_PX) { return false } - if (scrollTop > topEdgePx) { - return false - } - - if (wheelDeltaY !== undefined && !(wheelDeltaY < 0)) { - return false - } - - return true + // A wheel at the clamped top is intent only when it points up. + return wheelDeltaY === undefined || wheelDeltaY < 0 }