From 2c837c267fad802d4088d7a68aa371b9d48fe875 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 14:38:15 +0800 Subject: [PATCH] fix(web): address trajectory pagination review --- .../load-more.expected.md | 3 +- .../tests/trajectory-virtualization.e2e.ts | 9 +++++- .../src/client/TrajectoryTable.module.css | 9 ++++++ .../src/client/TrajectoryTable.tsx | 31 ++++++++++++++----- .../ui-trajectory/tests/table.client.spec.tsx | 9 ++++-- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/snapshots/trajectory-virtualization/load-more.expected.md b/apps/web/tests/snapshots/trajectory-virtualization/load-more.expected.md index f6d020f38c..c3c1302000 100644 --- a/apps/web/tests/snapshots/trajectory-virtualization/load-more.expected.md +++ b/apps/web/tests/snapshots/trajectory-virtualization/load-more.expected.md @@ -1,3 +1,4 @@ - row "Load earlier history": - cell "Load earlier history": - - button "Load earlier history" + - button "Load earlier history": + - status diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 4ed0e89fbc..16880295fd 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -157,10 +157,16 @@ async function loadToFirstTurn(page: Page): Promise { await scrollToRatio(page, 0) if (await page.getByText(marker, { exact: false }).count() > 0) return const before = await logicalRows(page) + const anchor = await firstVisibleRow(page) await expect.poll(async () => ({ marker: await page.getByText(marker, { exact: false }).count() > 0, rows: await logicalRows(page), }), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before }) + await nextPaint(page) + await expect.poll(async () => { + const top = await rowTop(page, anchor.key) + return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top) + }, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } throw new Error('trajectory did not reach the first turn after twelve older-page requests') } @@ -246,11 +252,12 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { scaffold.workspaceCwd, ) await compareOrRefreshGolden(LOAD_MORE_EXPECTED, loadMoreSnapshot, MODE) + // Avoid Playwright scrolling the offscreen first row into the automatic-load threshold. await loadMore.evaluate((button: HTMLButtonElement) => { button.click() }) await expect.poll(() => held, { timeout: 15_000 }).toBe(true) await expect.poll(async () => ({ disabled: await loadMore.isDisabled(), - label: await loadMore.textContent(), + label: await loadMore.getAttribute('aria-label'), }), { timeout: 15_000 }).toEqual({ disabled: true, label: 'Loading earlier history…', diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index fda3269372..71d0b3031b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -93,6 +93,15 @@ cursor: default; } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .table:not([data-scroll-ready='true']) { visibility: hidden; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 1a3e616ca4..dab72e6778 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -30,6 +30,7 @@ import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 const OLDER_LOAD_THRESHOLD_PX = 48 +const HISTORY_LOAD_ROW_HEIGHT_PX = 30 const VIRTUALIZATION_THRESHOLD = 100 const VIRTUAL_OVERSCAN_ROWS = 12 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 @@ -364,7 +365,7 @@ export interface TrajectoryTableProps { recordFocus?: { readonly index: number } | null /** Whether the initial history tail is still loading. */ historyLoading?: boolean - /** Whether another surface is loading one older history page. */ + /** Whether one older history page request is pending anywhere. */ olderHistoryLoading?: boolean /** First loaded raw event, used to preserve scroll position after prepending a page. */ historyStartSeq?: number | undefined @@ -1772,6 +1773,7 @@ export function TrajectoryTable({ const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows) const virtualizationEnabled = hasOlderRecords || records.length > VIRTUALIZATION_THRESHOLD + const virtualScrollMargin = hasOlderRecords ? HISTORY_LOAD_ROW_HEIGHT_PX : 0 const estimateVirtualRowSize = useCallback( (index: number) => virtualRowStructure[index]?.height ?? 30, [virtualRowStructure], @@ -1790,6 +1792,7 @@ export function TrajectoryTable({ initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX }, anchorTo: 'end', overscan: VIRTUAL_OVERSCAN_ROWS, + scrollMargin: virtualScrollMargin, scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX, }) const virtualIndexByRecordId = useMemo(() => { @@ -1804,10 +1807,15 @@ export function TrajectoryTable({ return indexes }, [projectedVirtualRows]) const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : [] - const virtualTop = virtualItems[0]?.start ?? 0 + const virtualTop = Math.max(0, (virtualItems[0]?.start ?? 0) - virtualScrollMargin) const virtualBottom = virtualItems.length === 0 ? 0 - : Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0)) + : Math.max( + 0, + rowVirtualizer.getTotalSize() + + virtualScrollMargin + - (virtualItems.at(-1)?.end ?? 0), + ) const renderedRecords = virtualizationEnabled ? virtualItems.flatMap((item) => { const row = projectedVirtualRows[item.index] @@ -2220,12 +2228,19 @@ export function TrajectoryTable({ {hasOlderRecords && ( - + diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 7e146cee12..467da8088a 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -418,7 +418,9 @@ describe('TrajectoryTable', () => { await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() }) expect(screen.getByRole('status').textContent).toContain('Loading earlier history…') resolveOlder?.(true) - await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() }) + await waitFor(() => { + expect(screen.getByRole('status').textContent).toBe('') + }) scrollHeight = 260 view.rerender( { const table = screen.getByRole('table') const loadButton = screen.getByRole('button', { name: 'Load earlier history' }) - expect(table.querySelector('tbody > tr:first-child')?.contains(loadButton)).toBe(true) + const loadRow = table.querySelector('tbody > tr:first-child') + expect(loadRow?.contains(loadButton)).toBe(true) + expect(loadRow?.getAttribute('aria-rowindex')).toBe('1') + expect(screen.getByRole('status').textContent).toBe('') expect(table.getAttribute('aria-rowcount')).toBe('4') expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex')) .toBe('2')