fix(ui-trajectory): follow live ledger tail
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/** Turn-aware trajectory event ledger with a local record inspector. */
|
/** 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 type { CSSProperties, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
IconChevronRightOutline14,
|
IconChevronRightOutline14,
|
||||||
@@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts'
|
|||||||
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
|
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
|
||||||
import css from './TrajectoryTable.module.css'
|
import css from './TrajectoryTable.module.css'
|
||||||
|
|
||||||
|
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||||
|
|
||||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||||
system: 'SYSTEM',
|
system: 'SYSTEM',
|
||||||
user: 'USER',
|
user: 'USER',
|
||||||
@@ -1711,6 +1713,9 @@ export function TrajectoryTable({
|
|||||||
// ledger has rendered. Not-found leaves the request pending (`turns` in the
|
// ledger has rendered. Not-found leaves the request pending (`turns` in the
|
||||||
// deps retries as history pages in); the ack clears the store field.
|
// deps retries as history pages in); the ack clears the store field.
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const tablePaneRef = useRef<HTMLDivElement>(null)
|
||||||
|
const followsTableTail = useRef(false)
|
||||||
|
const tableScrollInitialized = useRef(false)
|
||||||
const pendingScrollIndex = useRef<number | null>(null)
|
const pendingScrollIndex = useRef<number | null>(null)
|
||||||
const openRecordSummaryRef = useRef(openRecordSummary)
|
const openRecordSummaryRef = useRef(openRecordSummary)
|
||||||
openRecordSummaryRef.current = openRecordSummary
|
openRecordSummaryRef.current = openRecordSummary
|
||||||
@@ -1734,11 +1739,30 @@ export function TrajectoryTable({
|
|||||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
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 (
|
return (
|
||||||
<div ref={rootRef} className={css.split} style={splitStyle}>
|
<div ref={rootRef} className={css.split} style={splitStyle}>
|
||||||
<div
|
<div
|
||||||
|
ref={tablePaneRef}
|
||||||
className={css.tablePane}
|
className={css.tablePane}
|
||||||
|
onScroll={(event) => {
|
||||||
|
const pane = event.currentTarget
|
||||||
|
followsTableTail.current =
|
||||||
|
pane.scrollHeight - pane.clientHeight - pane.scrollTop
|
||||||
|
<= BOTTOM_FOLLOW_THRESHOLD_PX
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) clearAllSelections()
|
if (event.target === event.currentTarget) clearAllSelections()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -160,6 +160,53 @@ describe('TrajectoryTable', () => {
|
|||||||
expect(onClearSelection).toHaveBeenCalledOnce()
|
expect(onClearSelection).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('follows appended records only while the ledger is already at the bottom', () => {
|
||||||
|
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||||
|
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(
|
||||||
|
<TrajectoryTable
|
||||||
|
turns={[...TURNS, {
|
||||||
|
turn: 2,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
{...FOLD_PROPS}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(tablePane.scrollTop).toBe(260)
|
||||||
|
|
||||||
|
tablePane.scrollTop = 20
|
||||||
|
fireEvent.scroll(tablePane)
|
||||||
|
scrollHeight = 320
|
||||||
|
view.rerender(
|
||||||
|
<TrajectoryTable
|
||||||
|
turns={[...TURNS, {
|
||||||
|
turn: 2,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [
|
||||||
|
{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
|
||||||
|
{ index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
{...FOLD_PROPS}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(tablePane.scrollTop).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps running and failure semantics distinct from record roles', () => {
|
it('keeps running and failure semantics distinct from record roles', () => {
|
||||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||||
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
|
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
|
||||||
|
|||||||
Reference in New Issue
Block a user