Merge pull request #2474 from deepseek-harness/xtr/trajectory-load-more-row
fix(web): keep older trajectory history loadable
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: b3bc88d8ab73ccc0da9a49d1867fe2c66be5ca43
|
||||
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
|
||||
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面的加载尚未完成时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -55,6 +55,53 @@
|
||||
animation: history-loading-spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
.table tbody .historyLoadRow td {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The pagination control does not create a trajectory turn boundary. */
|
||||
.table tbody .historyLoadRow + tr[data-turn-start='true'] td::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.historyLoadButton {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 29px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.historyLoadButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.historyLoadButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.historyLoadButton:disabled {
|
||||
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;
|
||||
}
|
||||
@@ -137,7 +184,7 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]) {
|
||||
cursor: default;
|
||||
outline: none;
|
||||
transition:
|
||||
@@ -149,7 +196,7 @@
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]):not([data-selected='true']):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +365,8 @@ export interface TrajectoryTableProps {
|
||||
recordFocus?: { readonly index: number } | null
|
||||
/** Whether the initial history tail is still loading. */
|
||||
historyLoading?: boolean
|
||||
/** 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
|
||||
/** Whether one older history page can be requested. */
|
||||
@@ -1698,6 +1701,7 @@ export function TrajectoryTable({
|
||||
recordSelection = null,
|
||||
recordFocus = null,
|
||||
historyLoading = false,
|
||||
olderHistoryLoading = false,
|
||||
historyStartSeq,
|
||||
hasOlderRecords = false,
|
||||
onLoadOlder,
|
||||
@@ -1769,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],
|
||||
@@ -1787,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(() => {
|
||||
@@ -1801,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]
|
||||
@@ -2123,12 +2134,13 @@ export function TrajectoryTable({
|
||||
virtualIndexByRecordId,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
const requestOlder = useCallback((pane: HTMLDivElement) => {
|
||||
const requestOlder = useCallback((pane: HTMLDivElement, requireTop: boolean) => {
|
||||
if (
|
||||
!hasOlderRecords
|
||||
|| onLoadOlder === undefined
|
||||
|| loadingOlder.current
|
||||
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
|
||||
|| olderHistoryLoading
|
||||
|| (requireTop && pane.scrollTop > OLDER_LOAD_THRESHOLD_PX)
|
||||
) return
|
||||
loadingOlder.current = true
|
||||
setOlderLoading(true)
|
||||
@@ -2143,7 +2155,7 @@ export function TrajectoryTable({
|
||||
loadingOlder.current = false
|
||||
setOlderLoading(false)
|
||||
})
|
||||
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
|
||||
}, [hasOlderRecords, historyStartSeq, olderHistoryLoading, onLoadOlder])
|
||||
useLayoutEffect(() => {
|
||||
const pane = tablePaneRef.current
|
||||
if (pane === null) return
|
||||
@@ -2176,10 +2188,9 @@ export function TrajectoryTable({
|
||||
virtualizationEnabled,
|
||||
])
|
||||
|
||||
const loadingLabel = olderLoading
|
||||
? 'Loading earlier history…'
|
||||
: 'Loading trajectory…'
|
||||
const showLoading = historyLoading || olderLoading || !tableScrollReady
|
||||
const olderBusy = olderHistoryLoading || olderLoading
|
||||
const showInitialLoading = historyLoading || !tableScrollReady
|
||||
const historyRowOffset = hasOlderRecords ? 1 : 0
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.split} style={splitStyle}>
|
||||
@@ -2192,30 +2203,62 @@ export function TrajectoryTable({
|
||||
followsTableTail.current =
|
||||
pane.scrollHeight - pane.clientHeight - pane.scrollTop
|
||||
<= BOTTOM_FOLLOW_THRESHOLD_PX
|
||||
requestOlder(pane)
|
||||
requestOlder(pane, true)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) clearAllSelections()
|
||||
}}
|
||||
>
|
||||
{showLoading && (
|
||||
{showInitialLoading && (
|
||||
<div className={css.historyLoading} role="status" aria-live="polite">
|
||||
<span className={css.historyLoadingBar}>
|
||||
<span className={css.historyLoadingSpinner} aria-hidden="true" />
|
||||
{loadingLabel}
|
||||
Loading trajectory…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
className={css.table}
|
||||
data-scroll-ready={tableScrollReady || undefined}
|
||||
aria-rowcount={records.length}
|
||||
aria-rowcount={records.length + historyRowOffset}
|
||||
>
|
||||
<colgroup>
|
||||
<col className={css.eventColumn} />
|
||||
<col className={css.contentColumn} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{hasOlderRecords && (
|
||||
<tr
|
||||
className={css.historyLoadRow}
|
||||
data-history-load=""
|
||||
aria-rowindex={1}
|
||||
>
|
||||
<td colSpan={2}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.historyLoadButton}
|
||||
disabled={olderBusy || onLoadOlder === undefined}
|
||||
aria-label={olderBusy
|
||||
? 'Loading earlier history…'
|
||||
: 'Load earlier history'}
|
||||
onClick={() => {
|
||||
const pane = tablePaneRef.current
|
||||
if (pane !== null) requestOlder(pane, false)
|
||||
}}
|
||||
>
|
||||
{olderBusy && (
|
||||
<span className={css.historyLoadingSpinner} aria-hidden="true" />
|
||||
)}
|
||||
<span aria-hidden="true">
|
||||
{olderBusy ? 'Loading earlier history…' : 'Load earlier history'}
|
||||
</span>
|
||||
<span className={css.visuallyHidden} role="status" aria-live="polite">
|
||||
{olderBusy ? 'Loading earlier history…' : ''}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{virtualTop > 0 && (
|
||||
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
|
||||
<td
|
||||
@@ -2263,7 +2306,7 @@ export function TrajectoryTable({
|
||||
return (
|
||||
<tr
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-rowindex={position + 1 + historyRowOffset}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: isRequestOnly
|
||||
|
||||
@@ -141,8 +141,8 @@ export function TrajectoryView({
|
||||
} | null>(null)
|
||||
const inspection = useSession(snapshot =>
|
||||
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
|
||||
const historyLoading = useSession(snapshot =>
|
||||
snapshot.openState === 'loading' || snapshot.loadingOlder)
|
||||
const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
|
||||
const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
|
||||
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
|
||||
const nodes = inspection.eventNodes
|
||||
const eventLocations = inspection.eventLocations
|
||||
@@ -488,6 +488,7 @@ export function TrajectoryView({
|
||||
recordSelection={timelineRecordSelection}
|
||||
recordFocus={timelineRecordFocus}
|
||||
historyLoading={historyLoading}
|
||||
olderHistoryLoading={olderHistoryLoading}
|
||||
historyStartSeq={historyBaseSeq}
|
||||
hasOlderRecords={hasOlderHistory}
|
||||
onLoadOlder={loadEarlierHistory}
|
||||
|
||||
@@ -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(
|
||||
<TrajectoryTable
|
||||
@@ -438,6 +440,68 @@ describe('TrajectoryTable', () => {
|
||||
expect(tablePane.scrollTop).toBe(60)
|
||||
})
|
||||
|
||||
it('keeps an idle older-history control as the first row until paging completes', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
let resolveOlder: ((advanced: boolean) => void) | undefined
|
||||
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
|
||||
const onLoadOlder = vi.fn(() => older)
|
||||
const view = render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
hasOlderRecords
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
)
|
||||
|
||||
const table = screen.getByRole('table')
|
||||
const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
|
||||
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')
|
||||
|
||||
fireEvent.click(loadButton)
|
||||
expect(onLoadOlder).toHaveBeenCalledOnce()
|
||||
expect(loadButton.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
|
||||
|
||||
resolveOlder?.(false)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Load earlier history' })
|
||||
.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Load earlier history' })).toBeNull()
|
||||
expect(table.getAttribute('aria-rowcount')).toBe('3')
|
||||
})
|
||||
|
||||
it('reflects an older page started outside the ledger in the persistent control', () => {
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
hasOlderRecords
|
||||
olderHistoryLoading
|
||||
onLoadOlder={vi.fn(async () => true)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Loading earlier history…' })
|
||||
.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
|
||||
})
|
||||
|
||||
it('covers the ledger while the initial tail is loading', () => {
|
||||
const view = render(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
|
||||
|
||||
Reference in New Issue
Block a user