feat(trajectory): virtualize long session histories
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: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11
|
||||
README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad
|
||||
README.md: 18f8e637c0588a45c2bf33f7c8dbc36aee5def22
|
||||
README.zh.md: f81482cb4de7d5a76220145e99acde3aa4e33d17
|
||||
|
||||
@@ -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. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; 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 branch. 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. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. 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. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). 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. 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; 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 branch. 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. 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. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"diff": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -42,7 +43,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -51,8 +53,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
}
|
||||
|
||||
.tablePane {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -21,6 +22,53 @@
|
||||
container: trajectory-table / inline-size;
|
||||
}
|
||||
|
||||
.historyLoading {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.historyLoadingBar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.historyLoadingSpinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1.5px solid var(--dsw-alias-border-l2);
|
||||
border-top-color: var(--dsw-alias-state-business-primary);
|
||||
border-radius: 50%;
|
||||
animation: history-loading-spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
.table:not([data-scroll-ready='true']) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes history-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.historyLoadingSpinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
--trajectory-turn-accent: color-mix(
|
||||
in srgb,
|
||||
@@ -79,7 +127,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]) {
|
||||
.table tbody .virtualSpacer {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table tbody .virtualSpacer td {
|
||||
height: var(--trajectory-virtual-spacer-height);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
|
||||
cursor: default;
|
||||
outline: none;
|
||||
transition:
|
||||
@@ -91,7 +149,7 @@
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -620,6 +678,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolCallOnly {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.table tbody tr[data-collapsed-summary='turn'] td,
|
||||
.table tbody tr[data-collapsed-summary='assistant'] td {
|
||||
height: 20px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
IconChevronRightOutline14,
|
||||
IconSettingsOutline16,
|
||||
@@ -23,6 +24,12 @@ import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
|
||||
import css from './TrajectoryTable.module.css'
|
||||
|
||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||
const OLDER_LOAD_THRESHOLD_PX = 48
|
||||
const VIRTUALIZATION_THRESHOLD = 100
|
||||
const VIRTUAL_ROW_HEIGHT_PX = 30
|
||||
const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9
|
||||
const VIRTUAL_OVERSCAN_ROWS = 12
|
||||
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
|
||||
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
system: 'SYSTEM',
|
||||
@@ -195,6 +202,16 @@ type RequestBoundaryStyle = CSSProperties & {
|
||||
'--request-boundary-offset': string
|
||||
}
|
||||
|
||||
type VirtualSpacerStyle = CSSProperties & {
|
||||
'--trajectory-virtual-spacer-height': string
|
||||
}
|
||||
|
||||
interface OlderLoadAnchor {
|
||||
readonly historyStartSeq: number | undefined
|
||||
readonly scrollHeight: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
function clampDetailsWidth(width: number, splitWidth: number): number {
|
||||
const maxWidth = Math.max(
|
||||
DETAILS_MIN_WIDTH,
|
||||
@@ -312,6 +329,16 @@ export interface TrajectoryTableProps {
|
||||
onRecordSelect?: (index: number) => void
|
||||
/** One externally requested record selection; a new object repeats the request. */
|
||||
recordSelection?: { readonly index: number } | null
|
||||
/** One externally requested record focus without changing inspector selection. */
|
||||
recordFocus?: { readonly index: number } | null
|
||||
/** Whether the initial history tail is still loading. */
|
||||
historyLoading?: boolean
|
||||
/** First loaded history node, used to preserve scroll position after prepending a page. */
|
||||
historyStartSeq?: number | undefined
|
||||
/** Whether one older history page can be requested. */
|
||||
hasOlderRecords?: boolean
|
||||
/** Load one older history page. */
|
||||
onLoadOlder?: () => Promise<boolean>
|
||||
/** Clear selection state owned by the ledger host. */
|
||||
onClearSelection?: () => void
|
||||
/** Turn ids whose rows after the first are folded into a summary. */
|
||||
@@ -399,6 +426,11 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
})
|
||||
}
|
||||
|
||||
function virtualRecordHeight(record: TableRecord, final: boolean): number {
|
||||
if (record.cell.requestOnly !== true) return VIRTUAL_ROW_HEIGHT_PX
|
||||
return final ? VIRTUAL_FINAL_REQUEST_HEIGHT_PX : 0
|
||||
}
|
||||
|
||||
function filterRecords(
|
||||
records: readonly TableRecord[],
|
||||
matches: ReadonlySet<number>,
|
||||
@@ -492,7 +524,6 @@ function collapseTurnRecords(
|
||||
records: readonly TableRecord[],
|
||||
collapsedTurns: ReadonlySet<number>,
|
||||
): TableRecord[] {
|
||||
if (collapsedTurns.size === 0) return [...records]
|
||||
const recordsByTurn = new Map<number, TableRecord[]>()
|
||||
for (const record of records) {
|
||||
if (record.turn === null) continue
|
||||
@@ -552,7 +583,6 @@ function collapseAssistantRecords(
|
||||
records: readonly TableRecord[],
|
||||
collapsedAssistants: ReadonlySet<number>,
|
||||
): TableRecord[] {
|
||||
if (collapsedAssistants.size === 0) return [...records]
|
||||
const out: TableRecord[] = []
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
@@ -1538,6 +1568,11 @@ export function TrajectoryTable({
|
||||
onSelectedIndexChange,
|
||||
onRecordSelect,
|
||||
recordSelection = null,
|
||||
recordFocus = null,
|
||||
historyLoading = false,
|
||||
historyStartSeq,
|
||||
hasOlderRecords = false,
|
||||
onLoadOlder,
|
||||
onClearSelection,
|
||||
collapsedTurns,
|
||||
onToggleTurn,
|
||||
@@ -1554,18 +1589,65 @@ export function TrajectoryTable({
|
||||
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
||||
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
||||
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
|
||||
const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null)
|
||||
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const tablePaneRef = useRef<HTMLDivElement>(null)
|
||||
const followsTableTail = useRef(false)
|
||||
const tableScrollInitialized = useRef(false)
|
||||
const [tableScrollReady, setTableScrollReady] = useState(false)
|
||||
const pendingScrollIndex = useRef<number | null>(null)
|
||||
const loadingOlder = useRef(false)
|
||||
const [olderLoading, setOlderLoading] = useState(false)
|
||||
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
|
||||
useEffect(() => {
|
||||
onSelectedIndexChange?.(selectedIndex)
|
||||
}, [onSelectedIndexChange, selectedIndex])
|
||||
const allRecords = useMemo(() => flattenRecords(turns), [turns])
|
||||
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||
const records = searchMatchIndexes === null
|
||||
? collapseAssistantRecords(
|
||||
collapseTurnRecords(allRecords, collapsedTurns),
|
||||
collapsedAssistants,
|
||||
)
|
||||
: filterRecords(allRecords, searchMatchIndexes)
|
||||
const requestNumbers = useMemo(
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
|
||||
[allRecords, sessionRequestNumbers],
|
||||
)
|
||||
const records = useMemo(() => {
|
||||
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
|
||||
const turnRecords = collapsedTurns.size === 0
|
||||
? allRecords
|
||||
: collapseTurnRecords(allRecords, collapsedTurns)
|
||||
return collapsedAssistants.size === 0
|
||||
? turnRecords
|
||||
: collapseAssistantRecords(turnRecords, collapsedAssistants)
|
||||
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
|
||||
const virtualizationEnabled = records.length > VIRTUALIZATION_THRESHOLD
|
||||
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: virtualizationEnabled ? records.length : 0,
|
||||
enabled: virtualizationEnabled,
|
||||
estimateSize: (index) => {
|
||||
const record = records[index]
|
||||
return record === undefined
|
||||
? VIRTUAL_ROW_HEIGHT_PX
|
||||
: virtualRecordHeight(record, index === records.length - 1)
|
||||
},
|
||||
getItemKey: (index) => {
|
||||
const record = records[index]
|
||||
return record === undefined
|
||||
? index
|
||||
: `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`
|
||||
},
|
||||
getScrollElement: () => tablePaneRef.current,
|
||||
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
|
||||
overscan: VIRTUAL_OVERSCAN_ROWS,
|
||||
})
|
||||
const virtualRows = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
|
||||
const virtualTop = virtualRows[0]?.start ?? 0
|
||||
const virtualBottom = virtualRows.length === 0
|
||||
? 0
|
||||
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0))
|
||||
const renderedRecords = virtualizationEnabled
|
||||
? virtualRows.flatMap((row) => {
|
||||
const record = records[row.index]
|
||||
return record === undefined ? [] : [{ record, position: row.index }]
|
||||
})
|
||||
: records.map((record, position) => ({ record, position }))
|
||||
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
|
||||
const selected = allRecords.find(record => record.cell.index === selectedIndex)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
@@ -1697,7 +1779,13 @@ export function TrajectoryTable({
|
||||
) return
|
||||
appliedRecordSelection.current = recordSelection
|
||||
selectRecord(recordSelection.index)
|
||||
pendingScrollIndex.current = recordSelection.index
|
||||
}, [recordSelection, selectRecord])
|
||||
useEffect(() => {
|
||||
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
|
||||
appliedRecordFocus.current = recordFocus
|
||||
pendingScrollIndex.current = recordFocus.index
|
||||
}, [recordFocus])
|
||||
|
||||
const selectRequest = (
|
||||
request: SelectedRequest,
|
||||
@@ -1734,11 +1822,6 @@ export function TrajectoryTable({
|
||||
// open its summary, and remember the row to scroll once the un-collapsed
|
||||
// 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<HTMLDivElement>(null)
|
||||
const tablePaneRef = useRef<HTMLDivElement>(null)
|
||||
const followsTableTail = useRef(false)
|
||||
const tableScrollInitialized = useRef(false)
|
||||
const pendingScrollIndex = useRef<number | null>(null)
|
||||
const openRecordSummaryRef = useRef(openRecordSummary)
|
||||
openRecordSummaryRef.current = openRecordSummary
|
||||
useEffect(() => {
|
||||
@@ -1752,27 +1835,122 @@ export function TrajectoryTable({
|
||||
useEffect(() => {
|
||||
const index = pendingScrollIndex.current
|
||||
if (index === null) return
|
||||
const position = records.findIndex(record =>
|
||||
record.cell.index === index && record.collapsedSummary === undefined)
|
||||
if (position === -1) return
|
||||
pendingScrollIndex.current = null
|
||||
if (virtualizationEnabled) {
|
||||
rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' })
|
||||
return
|
||||
}
|
||||
const row = rootRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
if (row === undefined || row === null) return
|
||||
pendingScrollIndex.current = null
|
||||
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
|
||||
if (typeof row.scrollIntoView === 'function') {
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}, [records, rowVirtualizer, virtualizationEnabled])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const focusedPositions = records.flatMap((record, position) =>
|
||||
record.collapsedSummary === undefined
|
||||
&& record.cell.requestOnly !== true
|
||||
&& timelineFocusIndexes.has(record.cell.index)
|
||||
? [position]
|
||||
: [])
|
||||
const first = focusedPositions.at(0)
|
||||
const last = focusedPositions.at(-1)
|
||||
if (first === undefined || last === undefined) return
|
||||
if (!virtualizationEnabled) {
|
||||
const ledger = rootRef.current
|
||||
if (ledger === null) return
|
||||
const focusedRows = [
|
||||
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
|
||||
]
|
||||
const firstRow = focusedRows.at(0)
|
||||
const lastRow = focusedRows.at(-1)
|
||||
if (firstRow === undefined || lastRow === undefined) return
|
||||
const focusHeight =
|
||||
lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top
|
||||
const target = focusHeight > ledger.clientHeight
|
||||
? firstRow
|
||||
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
|
||||
if (target !== undefined && typeof target.scrollIntoView === 'function') {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
|
||||
let focusHeight = 0
|
||||
for (let position = first; position <= last; position++) {
|
||||
const record = records[position]
|
||||
if (record === undefined) continue
|
||||
focusHeight += virtualRecordHeight(
|
||||
record,
|
||||
position === records.length - 1,
|
||||
)
|
||||
}
|
||||
rowVirtualizer.scrollToIndex(
|
||||
focusHeight > paneHeight ? first : focusedPositions[Math.floor((focusedPositions.length - 1) / 2)] ?? first,
|
||||
{
|
||||
behavior: 'smooth',
|
||||
align: focusHeight > paneHeight ? 'start' : 'center',
|
||||
},
|
||||
)
|
||||
}, [records, rowVirtualizer, timelineFocusIndexes, virtualizationEnabled])
|
||||
const requestOlder = useCallback((pane: HTMLDivElement) => {
|
||||
if (
|
||||
!hasOlderRecords
|
||||
|| onLoadOlder === undefined
|
||||
|| loadingOlder.current
|
||||
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
|
||||
) return
|
||||
loadingOlder.current = true
|
||||
setOlderLoading(true)
|
||||
olderLoadAnchor.current = {
|
||||
historyStartSeq,
|
||||
scrollHeight: pane.scrollHeight,
|
||||
scrollTop: pane.scrollTop,
|
||||
}
|
||||
void onLoadOlder().then((advanced) => {
|
||||
if (!advanced) olderLoadAnchor.current = null
|
||||
}).finally(() => {
|
||||
loadingOlder.current = false
|
||||
setOlderLoading(false)
|
||||
})
|
||||
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
|
||||
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
|
||||
const anchor = olderLoadAnchor.current
|
||||
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
|
||||
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
|
||||
olderLoadAnchor.current = null
|
||||
followsTableTail.current = false
|
||||
return
|
||||
}
|
||||
if (followsTableTail.current) pane.scrollTop = pane.scrollHeight
|
||||
}, [turns])
|
||||
if (!tableScrollInitialized.current) {
|
||||
if (historyLoading) return
|
||||
tableScrollInitialized.current = true
|
||||
followsTableTail.current = true
|
||||
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
|
||||
else pane.scrollTop = pane.scrollHeight
|
||||
setTableScrollReady(true)
|
||||
return
|
||||
}
|
||||
if (!followsTableTail.current) return
|
||||
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
|
||||
else pane.scrollTop = pane.scrollHeight
|
||||
}, [historyLoading, historyStartSeq, rowVirtualizer, turns, virtualizationEnabled])
|
||||
|
||||
const loadingLabel = olderLoading
|
||||
? 'Loading earlier history…'
|
||||
: 'Loading trajectory…'
|
||||
const showLoading = historyLoading || olderLoading || !tableScrollReady
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.split} style={splitStyle}>
|
||||
@@ -1784,23 +1962,48 @@ export function TrajectoryTable({
|
||||
followsTableTail.current =
|
||||
pane.scrollHeight - pane.clientHeight - pane.scrollTop
|
||||
<= BOTTOM_FOLLOW_THRESHOLD_PX
|
||||
requestOlder(pane)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) clearAllSelections()
|
||||
}}
|
||||
>
|
||||
<table className={css.table}>
|
||||
{showLoading && (
|
||||
<div className={css.historyLoading} role="status" aria-live="polite">
|
||||
<span className={css.historyLoadingBar}>
|
||||
<span className={css.historyLoadingSpinner} aria-hidden="true" />
|
||||
{loadingLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
className={css.table}
|
||||
data-scroll-ready={tableScrollReady || undefined}
|
||||
>
|
||||
<colgroup>
|
||||
<col className={css.eventColumn} />
|
||||
<col className={css.contentColumn} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
{virtualTop > 0 && (
|
||||
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{
|
||||
'--trajectory-virtual-spacer-height': `${virtualTop}px`,
|
||||
} as VirtualSpacerStyle}
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
{renderedRecords.map(({ record, position }) => {
|
||||
const displayText = recordDisplayText(record.cell)
|
||||
const toolCallOnly = isToolCallOnly(record.cell)
|
||||
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
||||
const listDisplayText = toolCallText === undefined
|
||||
? displayText
|
||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
||||
const listDisplayText = toolCallOnly
|
||||
? '(tool call only)'
|
||||
: toolCallText === undefined
|
||||
? displayText
|
||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
||||
const isCollapsedSummary = record.collapsedSummary !== undefined
|
||||
const isRequestOnly = record.cell.requestOnly === true
|
||||
const isInitialSystem = record.cell.kind === 'system'
|
||||
@@ -1840,6 +2043,7 @@ export function TrajectoryTable({
|
||||
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
|
||||
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
|
||||
data-kind={record.cell.kind}
|
||||
data-virtual-position={virtualizationEnabled ? position : undefined}
|
||||
data-record-index={!isCollapsedSummary && !isRequestOnly
|
||||
? record.cell.index
|
||||
: undefined}
|
||||
@@ -2013,8 +2217,8 @@ export function TrajectoryTable({
|
||||
: `${listDisplayText} → ${record.cell.result}`}
|
||||
>
|
||||
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
|
||||
{isToolCallOnly(record.cell)
|
||||
? null
|
||||
{toolCallOnly
|
||||
? <span className={css.toolCallOnly}>(tool call only)</span>
|
||||
: toolCallText === undefined
|
||||
? listDisplayText || '—'
|
||||
: (
|
||||
@@ -2047,6 +2251,16 @@ export function TrajectoryTable({
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{virtualBottom > 0 && (
|
||||
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{
|
||||
'--trajectory-virtual-spacer-height': `${virtualBottom}px`,
|
||||
} as VirtualSpacerStyle}
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,46 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.earlierHistory {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 28px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
padding-left: 3px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--dsw-alias-bg-layer-2) 0,
|
||||
var(--dsw-alias-bg-layer-2) 38%,
|
||||
transparent 100%
|
||||
);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
line-height: 1;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earlierHistory:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earlierHistory[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earlierHistory:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -115,7 +155,7 @@
|
||||
top: calc(var(--trajectory-span-lane) * 14px);
|
||||
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
|
||||
width: max(
|
||||
2px,
|
||||
0px,
|
||||
calc(
|
||||
var(--trajectory-span-width)
|
||||
- var(--trajectory-span-gap)
|
||||
@@ -123,7 +163,6 @@
|
||||
)
|
||||
);
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
opacity: 0.78;
|
||||
|
||||
@@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps {
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
mode: TrajectoryTimelineMode
|
||||
range: TrajectoryTimeRange | null
|
||||
/** Whether the loaded timeline omits an earlier history prefix. */
|
||||
hasEarlierRecords?: boolean
|
||||
/** Load one earlier history page from the truncation control. */
|
||||
onLoadEarlier?: () => Promise<boolean>
|
||||
selectedIndex?: number | null
|
||||
/** Record indexes matching the active ledger search, or null without a query. */
|
||||
searchMatchIndexes?: ReadonlySet<number> | null
|
||||
@@ -191,11 +195,49 @@ function LaneLabels() {
|
||||
)
|
||||
}
|
||||
|
||||
function EarlierHistoryBoundary({
|
||||
loading,
|
||||
onHover,
|
||||
onLoad,
|
||||
}: {
|
||||
loading: boolean
|
||||
onHover: () => void
|
||||
onLoad: (() => void) | undefined
|
||||
}) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={loading ? 'Loading earlier history…' : 'Click to load earlier history'}
|
||||
side="right"
|
||||
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.earlierHistory}
|
||||
data-earlier-history
|
||||
data-loading={loading || undefined}
|
||||
aria-label={loading ? 'Loading earlier history' : 'Load earlier history'}
|
||||
aria-disabled={loading || onLoad === undefined}
|
||||
onClick={onLoad}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
onHover()
|
||||
}}
|
||||
onPointerMove={(event) => { event.stopPropagation() }}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
|
||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
turns,
|
||||
mode,
|
||||
range,
|
||||
hasEarlierRecords = false,
|
||||
onLoadEarlier,
|
||||
selectedIndex = null,
|
||||
searchMatchIndexes = null,
|
||||
onRangeChange,
|
||||
@@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
|
||||
const [hover, setHover] = useState<HoverPoint | null>(null)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const [panning, setPanning] = useState(false)
|
||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||
const [animateViewport, setAnimateViewport] = useState(false)
|
||||
@@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
)
|
||||
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
||||
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
|
||||
const showsEarlierBoundary = hasEarlierRecords
|
||||
&& model !== null
|
||||
&& domainStart === model.start
|
||||
const loadEarlier = onLoadEarlier === undefined || loadingEarlier
|
||||
? undefined
|
||||
: () => {
|
||||
setLoadingEarlier(true)
|
||||
void onLoadEarlier().finally(() => { setLoadingEarlier(false) })
|
||||
}
|
||||
const projectedDomainStyle = model === null
|
||||
? undefined
|
||||
: {
|
||||
@@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
<LaneLabels />
|
||||
<div className={css.track}>
|
||||
<span className={css.empty}>No timing data</span>
|
||||
{hasEarlierRecords && (
|
||||
<EarlierHistoryBoundary
|
||||
loading={loadingEarlier}
|
||||
onHover={() => { setHover(null) }}
|
||||
onLoad={loadEarlier}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{showsEarlierBoundary && (
|
||||
<EarlierHistoryBoundary
|
||||
loading={loadingEarlier}
|
||||
onHover={() => { setHover(null) }}
|
||||
onLoad={loadEarlier}
|
||||
/>
|
||||
)}
|
||||
{hover !== null && hover.recordIndex === null && draft === null && (
|
||||
<div
|
||||
className={css.hoverLine}
|
||||
@@ -609,7 +675,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
.map((span) => {
|
||||
const left = (span.start - model.start) / fullDuration
|
||||
const width = (span.end - span.start) / fullDuration
|
||||
const widthPercent = Math.max(width * 100, 0.35)
|
||||
const widthPercent = width * 100
|
||||
const detail = detailByIndex.get(span.index)
|
||||
const ttftMs = detail?.ttftMs
|
||||
const decodingMs = detail?.decodingMs
|
||||
@@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
style={{
|
||||
'--trajectory-span-left': `${left * 100}%`,
|
||||
'--trajectory-span-width': `${widthPercent}%`,
|
||||
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
|
||||
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
|
||||
'--trajectory-span-lane': span.lane,
|
||||
...(ttftFraction === null
|
||||
? {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext,
|
||||
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
|
||||
SessionHistoryFace, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from './TrajectoryTable.tsx'
|
||||
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import {
|
||||
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
|
||||
type TrajectoryTurnModel,
|
||||
} from './layout.ts'
|
||||
import {
|
||||
trajectoryTimelineFocusIndexes,
|
||||
type TrajectoryTimelineMode,
|
||||
@@ -27,13 +30,45 @@ import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
|
||||
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
|
||||
let last = 0
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) last = Math.max(last, cell.index)
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
function timelineBlock(block: AssistantBlock): AssistantBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { kind: 'text', text: '' }
|
||||
case 'reasoning': return { kind: 'reasoning', text: '' }
|
||||
case 'tool-call': return {
|
||||
kind: 'tool-call',
|
||||
callId: block.callId,
|
||||
name: block.name,
|
||||
argsRaw: '',
|
||||
}
|
||||
case 'other': return { kind: 'other', block: null }
|
||||
}
|
||||
}
|
||||
|
||||
function partialStructureSignature(partial: ConversationSnapshot['partial']): string {
|
||||
if (partial === null) return ''
|
||||
return partial.blocks.map(block => block.kind === 'tool-call'
|
||||
? `${block.kind}:${block.callId}:${block.name}`
|
||||
: block.kind).join('\u0000')
|
||||
}
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: {
|
||||
history: SessionHistoryFace
|
||||
duration: SnapshotStore<boolean>
|
||||
}
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
loadHistoryTail: (signal: AbortSignal) => Promise<void>
|
||||
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
}
|
||||
|
||||
@@ -138,7 +173,8 @@ function searchMatches(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
|
||||
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
|
||||
inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
@@ -154,26 +190,35 @@ export function TrajectoryView({
|
||||
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||
readonly index: number
|
||||
} | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
|
||||
readonly index: number
|
||||
} | null>(null)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const historyLoading = useHistory(snapshot =>
|
||||
snapshot.state === 'cold' || snapshot.state === 'loading')
|
||||
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
|
||||
const nodes = inspection.eventNodes
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const codeDispatches = inspection.codeDispatches
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
const loadHistoryTailRef = useRef(loadHistoryTail)
|
||||
loadHistoryTailRef.current = loadHistoryTail
|
||||
const historyControllerRef = useRef<AbortController | null>(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
historyControllerRef.current = controller
|
||||
void loadHistoryTailRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
const requests = inspection.requests
|
||||
const callSchemas = inspection.callSchemas
|
||||
const historyContexts = inspection.contexts
|
||||
const interruptedNodes = inspection.interruptedNodes
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => inspection.contexts.length === 0
|
||||
() => historyContexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
: historyContexts,
|
||||
[historyContexts, nodes],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
@@ -183,18 +228,18 @@ export function TrajectoryView({
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of inspection.interruptedNodes) {
|
||||
for (const node of interruptedNodes) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, inspection])
|
||||
}, [currentBranch.nodes, interruptedNodes])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
),
|
||||
[currentBranch, requests],
|
||||
)
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
for (const node of context.nodes) {
|
||||
@@ -295,47 +340,44 @@ export function TrajectoryView({
|
||||
})
|
||||
}
|
||||
|
||||
if (partial !== null && partial.step > 0) {
|
||||
const key = `${partial.turn}\u0000${partial.step}`
|
||||
const recorded = numbered.some(request =>
|
||||
`${request.turn}\u0000${request.step}` === key,
|
||||
)
|
||||
if (!recorded) {
|
||||
numbered.push({
|
||||
turn: partial.turn,
|
||||
step: partial.step,
|
||||
group: `Step ${partial.step}`,
|
||||
number: orderedRequests.length + 1,
|
||||
...(currentBranch.latest.prompt?.config.provider === undefined
|
||||
? {}
|
||||
: { provider: currentBranch.latest.prompt.config.provider }),
|
||||
...(currentBranch.latest.prompt?.config.model === undefined
|
||||
? {}
|
||||
: { model: currentBranch.latest.prompt.config.model }),
|
||||
...(currentBranch.latest.prompt?.config === undefined
|
||||
? {}
|
||||
: { requestConfig: currentBranch.latest.prompt.config }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
}
|
||||
return numbered
|
||||
}, [
|
||||
contexts, currentBranch.latest.prompt, nodes, partial, requests,
|
||||
contexts, nodes, requests,
|
||||
])
|
||||
const requestNumbers = globalRequestNumbers
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
const partialTurn = partial?.turn ?? null
|
||||
const partialStep = partial?.step ?? null
|
||||
const finalized = useMemo(() => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial,
|
||||
partial: partialTurn === null || partialStep === null
|
||||
? null
|
||||
: { turn: partialTurn, step: partialStep, blocks: [] },
|
||||
runningCalls,
|
||||
requests: selectedRequests,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
],
|
||||
})
|
||||
return { turns, lastIndex: lastCellIndex(turns) }
|
||||
}, [
|
||||
selectedNodes, partialTurn, partialStep,
|
||||
runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
])
|
||||
const turns = useMemo(
|
||||
() => appendTrajectoryPartialLayout(finalized.turns, partial, finalized.lastIndex),
|
||||
[finalized, partial],
|
||||
)
|
||||
const timelinePartialSignature = partialStructureSignature(partial)
|
||||
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
|
||||
? null
|
||||
: {
|
||||
turn: partial.turn,
|
||||
step: partial.step,
|
||||
blocks: partial.blocks.map(block => timelineBlock(block)),
|
||||
},
|
||||
[partialStep, partialTurn, timelinePartialSignature])
|
||||
const timelineTurns = useMemo(
|
||||
() => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex),
|
||||
[finalized, timelinePartial],
|
||||
)
|
||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||
? actualTime ? 'actual' : 'duration'
|
||||
@@ -350,8 +392,8 @@ export function TrajectoryView({
|
||||
const timelineFocusIndexes = useMemo(
|
||||
() => timelineRange === null
|
||||
? null
|
||||
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
|
||||
[timelineMode, timelineRange, turns],
|
||||
: trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode),
|
||||
[timelineMode, timelineRange, timelineTurns],
|
||||
)
|
||||
const handleRecordSelect = useCallback((index: number) => {
|
||||
if (
|
||||
@@ -361,29 +403,6 @@ export function TrajectoryView({
|
||||
setTimelineSelection(null)
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const ledger = ledgerRef.current
|
||||
if (ledger === null) return
|
||||
const focusedRows = [
|
||||
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
|
||||
]
|
||||
const first = focusedRows.at(0)
|
||||
const last = focusedRows.at(-1)
|
||||
if (first === undefined || last === undefined) return
|
||||
const focusHeight =
|
||||
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
|
||||
if (focusHeight > ledger.clientHeight) {
|
||||
if (typeof first.scrollIntoView === 'function') {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
return
|
||||
}
|
||||
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
|
||||
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
.filter(turn =>
|
||||
@@ -458,6 +477,13 @@ export function TrajectoryView({
|
||||
})
|
||||
}
|
||||
|
||||
const loadEarlierHistory = useCallback(() => {
|
||||
const signal = historyControllerRef.current?.signal
|
||||
return signal?.aborted === false
|
||||
? loadOlderHistory(signal)
|
||||
: Promise.resolve(false)
|
||||
}, [loadOlderHistory])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
@@ -479,9 +505,11 @@ export function TrajectoryView({
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
turns={timelineTurns}
|
||||
mode={timelineMode}
|
||||
range={timelineRange}
|
||||
hasEarlierRecords={hasOlderHistory}
|
||||
onLoadEarlier={loadEarlierHistory}
|
||||
selectedIndex={selectedTimelineIndex}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
@@ -494,21 +522,12 @@ export function TrajectoryView({
|
||||
setTimelineSelection(null)
|
||||
setTimelineRecordSelection({ index })
|
||||
setSelectedTimelineIndex(index)
|
||||
const row = ledgerRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}}
|
||||
onRecordFocus={(index) => {
|
||||
const row = ledgerRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
setTimelineRecordFocus({ index })
|
||||
}}
|
||||
/>
|
||||
<div ref={ledgerRef} className={css.ledger}>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
requestNumbers={requestNumbers}
|
||||
@@ -518,6 +537,11 @@ export function TrajectoryView({
|
||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||
onRecordSelect={handleRecordSelect}
|
||||
recordSelection={timelineRecordSelection}
|
||||
recordFocus={timelineRecordFocus}
|
||||
historyLoading={historyLoading}
|
||||
historyStartSeq={nodes.at(0)?.seq}
|
||||
hasOlderRecords={hasOlderHistory}
|
||||
onLoadOlder={loadEarlierHistory}
|
||||
onClearSelection={() => { setTimelineSelection(null) }}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
|
||||
@@ -35,7 +35,8 @@ export function apply(ctx: Context): void {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
hooks: { history, duration },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
loadHistoryTail: signal => history.loadTail(signal),
|
||||
loadOlderHistory: signal => history.loadOlder(signal),
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -475,6 +475,61 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the changing in-flight assistant cells to a stable finalized layout.
|
||||
* @param turns - Finalized layout derived with an empty-block partial anchor.
|
||||
* @param partial - Current in-flight assistant projection.
|
||||
* @param lastIndex - Highest cell index in the finalized layout.
|
||||
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
|
||||
*/
|
||||
export function appendTrajectoryPartialLayout(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastIndex: number,
|
||||
): readonly TrajectoryTurnModel[] {
|
||||
if (partial === null) return turns
|
||||
const partialTurn = deriveTrajectoryLayout({
|
||||
nodes: [],
|
||||
partial,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
}).at(0)
|
||||
if (partialTurn === undefined) return turns
|
||||
const streamed: TrajectoryTurnModel = {
|
||||
...partialTurn,
|
||||
groups: partialTurn.groups.map(group => ({
|
||||
...group,
|
||||
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
|
||||
})),
|
||||
}
|
||||
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
|
||||
if (turnIndex === -1) return [...turns, streamed]
|
||||
const current = turns[turnIndex]
|
||||
/* v8 ignore next -- findIndex proved the dense array position exists. */
|
||||
if (current === undefined) return turns
|
||||
const groups = [...current.groups]
|
||||
for (const streamedGroup of streamed.groups) {
|
||||
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
|
||||
if (groupIndex === -1) {
|
||||
groups.push(streamedGroup)
|
||||
continue
|
||||
}
|
||||
const group = groups[groupIndex]
|
||||
/* v8 ignore next -- findIndex proved the dense array position exists. */
|
||||
if (group === undefined) continue
|
||||
groups[groupIndex] = {
|
||||
...streamedGroup,
|
||||
cells: [
|
||||
...group.cells.filter(cell => cell.requestOnly !== true),
|
||||
...streamedGroup.cells,
|
||||
],
|
||||
}
|
||||
}
|
||||
const updated = [...turns]
|
||||
updated[turnIndex] = { ...current, groups }
|
||||
return updated
|
||||
}
|
||||
|
||||
function attachToolSchema(
|
||||
laid: LaidCell,
|
||||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||||
@@ -566,6 +621,7 @@ function expandAssistant(
|
||||
callStarts: ReadonlyMap<string, number>,
|
||||
opts?: { streaming?: boolean },
|
||||
): LaidCell[] {
|
||||
if (opts?.streaming === true && node.blocks.length === 0) return []
|
||||
const out: LaidCell[] = []
|
||||
let index = startIndex - 1
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
|
||||
const modules = new Map<string, unknown>([
|
||||
['react', await import('react')],
|
||||
['react/jsx-runtime', await import('react/jsx-runtime')],
|
||||
['react-dom', await import('react-dom')],
|
||||
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
|
||||
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
|
||||
])
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
|
||||
import {
|
||||
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
|
||||
} from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -102,6 +104,42 @@ describe('deriveTrajectoryLayout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
|
||||
const nodes = [{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'finalized' }],
|
||||
}] as unknown as ConversationSnapshot['nodes']
|
||||
const partial = {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
|
||||
}
|
||||
const request = {
|
||||
purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
|
||||
startedAt: 3_000, completedAt: null, status: 'running',
|
||||
} as unknown as RequestView
|
||||
const base = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes,
|
||||
partial: { ...partial, blocks: [] },
|
||||
requests: [request],
|
||||
runningCalls: [],
|
||||
})
|
||||
expect(base).toHaveLength(1)
|
||||
|
||||
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
|
||||
|
||||
expect(streamed[0]).toBe(base[0])
|
||||
expect(streamed).toHaveLength(2)
|
||||
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
text: 'streaming',
|
||||
timeSeconds: null,
|
||||
}])
|
||||
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits duration when node times are missing instead of rendering NaN', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
/** Trajectory ledger selection, details, status, and fold behavior. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
|
||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
|
||||
})
|
||||
|
||||
const TURNS: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
@@ -61,6 +65,28 @@ const FOLD_PROPS = {
|
||||
}
|
||||
|
||||
describe('TrajectoryTable', () => {
|
||||
it('shows a muted placeholder for an assistant response containing only tool calls', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: 'Tool call only',
|
||||
sourceBlocks: [{
|
||||
type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
|
||||
}],
|
||||
timeSeconds: 1,
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
expect(screen.getByText('(tool call only)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows assistant timing facts after keyboard selection', () => {
|
||||
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
|
||||
@@ -210,6 +236,109 @@ describe('TrajectoryTable', () => {
|
||||
expect(tablePane.scrollTop).toBe(20)
|
||||
})
|
||||
|
||||
it('loads one older page at the top and preserves the visible anchor', async () => {
|
||||
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}
|
||||
historyStartSeq={1}
|
||||
hasOlderRecords
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
)
|
||||
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 = 0
|
||||
fireEvent.scroll(tablePane)
|
||||
fireEvent.scroll(tablePane)
|
||||
|
||||
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
|
||||
resolveOlder?.(true)
|
||||
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
|
||||
scrollHeight = 260
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[{
|
||||
turn: 0,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
|
||||
}],
|
||||
}, ...TURNS]}
|
||||
{...FOLD_PROPS}
|
||||
historyStartSeq={0}
|
||||
hasOlderRecords
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(tablePane.scrollTop).toBe(60)
|
||||
})
|
||||
|
||||
it('covers the ledger while the initial tail is loading', () => {
|
||||
const view = render(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
|
||||
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
|
||||
|
||||
view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
|
||||
})
|
||||
|
||||
it('mounts only the visible window for a long ledger', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
})
|
||||
const cells = Array.from({ length: 500 }, (_, index) => ({
|
||||
index: index + 1,
|
||||
kind: 'context' as const,
|
||||
text: `Context ${index + 1}`,
|
||||
timeSeconds: 0,
|
||||
}))
|
||||
const turns: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{ title: 'Context', cells }],
|
||||
}]
|
||||
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
|
||||
.toBeLessThan(cells.length)
|
||||
expect(scrollTo).toHaveBeenCalled()
|
||||
expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
|
||||
expect(screen.getByText('Context 1')).toBeTruthy()
|
||||
expect(screen.queryByText('Context 500')).toBeNull()
|
||||
|
||||
const tablePane = screen.getByRole('table').parentElement as HTMLElement
|
||||
tablePane.scrollTop = 9_000
|
||||
fireEvent.scroll(tablePane)
|
||||
await waitFor(() => {
|
||||
expect(Number(view.container.querySelector(
|
||||
'tr[data-virtual-position]',
|
||||
)?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
|
||||
})
|
||||
expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
|
||||
expect(screen.queryByText('Context 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps running and failure semantics distinct from record roles', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
|
||||
|
||||
@@ -89,11 +89,15 @@ function historySnapshot(
|
||||
|
||||
function standaloneHistory(
|
||||
snapshot: SessionHistorySnapshot,
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
|
||||
): Pick<
|
||||
ComponentProps<typeof TrajectoryView>,
|
||||
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
|
||||
> {
|
||||
const store = createSnapshotStore(snapshot)
|
||||
return {
|
||||
useHistory: bindSnapshotSelector(store),
|
||||
loadAllHistory: () => Promise.resolve(),
|
||||
loadHistoryTail: () => Promise.resolve(),
|
||||
loadOlderHistory: () => Promise.resolve(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,13 +149,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
|
||||
const historyStore = createSnapshotStore(snapshot)
|
||||
const history: SessionHistoryFace = {
|
||||
sessionId: SID,
|
||||
getSnapshot: () => historyStore.getSnapshot(),
|
||||
subscribe: listener => historyStore.subscribe(listener),
|
||||
loadAll: loadAllHistory,
|
||||
loadTail: loadHistoryTail,
|
||||
loadOlder: loadOlderHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
@@ -167,7 +173,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, loadAllHistory }
|
||||
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
|
||||
}
|
||||
|
||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||
@@ -201,7 +207,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
? (() => {
|
||||
const trajectory = injected as TrajectoryViewInjected
|
||||
return {
|
||||
loadAllHistory: trajectory.loadAllHistory,
|
||||
loadHistoryTail: trajectory.loadHistoryTail,
|
||||
loadOlderHistory: trajectory.loadOlderHistory,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
@@ -295,9 +302,9 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.loadAllHistory).toHaveBeenCalledOnce()
|
||||
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
|
||||
})
|
||||
const signal = b.loadAllHistory.mock.calls[0]?.[0]
|
||||
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||
expect(signal?.aborted).toBe(true)
|
||||
@@ -580,6 +587,45 @@ describe('timeline projection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('marks an unloaded history prefix without inventing timeline duration', () => {
|
||||
const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
hasEarlierRecords
|
||||
onLoadEarlier={onLoadEarlier}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const boundary = screen.getByLabelText('Load earlier history')
|
||||
expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
|
||||
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
|
||||
fireEvent.pointerEnter(boundary)
|
||||
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
|
||||
fireEvent.focus(boundary)
|
||||
expect(screen.getByRole('tooltip').textContent)
|
||||
.toContain('Click to load earlier history')
|
||||
fireEvent.click(boundary)
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Loading earlier history')).toBeTruthy()
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
|
||||
expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels native scrolling across the timeline while zooming', () => {
|
||||
render(
|
||||
<TrajectoryTimeline
|
||||
@@ -614,7 +660,35 @@ describe('timeline projection', () => {
|
||||
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
|
||||
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
|
||||
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
|
||||
.toBe('clamp(0.25px, 0.8%, 1px)')
|
||||
.toBe('min(0.8%, 1px)')
|
||||
})
|
||||
|
||||
it('keeps dense sequence spans proportional before applying the pixel floor', () => {
|
||||
const denseTurns = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: Array.from({ length: 400 }, (_, index) => ({
|
||||
index,
|
||||
kind: 'message' as const,
|
||||
text: `message ${index}`,
|
||||
timeSeconds: 1,
|
||||
})),
|
||||
}],
|
||||
}]
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={denseTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
|
||||
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
|
||||
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
|
||||
.toBe('min(0.02%, 1px)')
|
||||
})
|
||||
|
||||
it('clears the selection without changing zoom on a zoomed right click', () => {
|
||||
@@ -624,15 +698,18 @@ describe('timeline projection', () => {
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={{ start: 2, end: 4 }}
|
||||
hasEarlierRecords
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
|
||||
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
|
||||
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
|
||||
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
|
||||
expect(domainWidth).not.toBe('100%')
|
||||
@@ -1065,7 +1142,8 @@ describe('TrajectoryView branches', () => {
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1108,7 +1186,8 @@ describe('TrajectoryView branches', () => {
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user