From c8ece8325c34a7376f753ef74d46ee18867c73b9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:47:55 +0800 Subject: [PATCH] fix(ui-trajectory): follow live ledger tail --- .../src/client/TrajectoryTable.tsx | 26 +++++++++- .../client/ui-trajectory/tests/table.spec.tsx | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 4f88bf0bca..7973649cf5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { IconChevronRightOutline14, @@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' +const BOTTOM_FOLLOW_THRESHOLD_PX = 2 + const KIND_LABEL: Record = { system: 'SYSTEM', user: 'USER', @@ -1711,6 +1713,9 @@ export function TrajectoryTable({ // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. const rootRef = useRef(null) + const tablePaneRef = useRef(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) const pendingScrollIndex = useRef(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary @@ -1734,11 +1739,30 @@ export function TrajectoryTable({ row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) + useLayoutEffect(() => { + const pane = tablePaneRef.current + if (pane === null) return + if (!tableScrollInitialized.current) { + tableScrollInitialized.current = true + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + return + } + if (followsTableTail.current) pane.scrollTop = pane.scrollHeight + }, [turns]) return (
{ + const pane = event.currentTarget + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index f02eb6e0cc..65c3da3255 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -160,6 +160,53 @@ describe('TrajectoryTable', () => { expect(onClearSelection).toHaveBeenCalledOnce() }) + it('follows appended records only while the ledger is already at the bottom', () => { + const view = render() + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 100 + fireEvent.scroll(tablePane) + + scrollHeight = 260 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(260) + + tablePane.scrollTop = 20 + fireEvent.scroll(tablePane) + scrollHeight = 320 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(20) + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()