fix(trajectory): bound work during long streams
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: 18f8e637c0588a45c2bf33f7c8dbc36aee5def22
|
||||
README.zh.md: f81482cb4de7d5a76220145e99acde3aa4e33d17
|
||||
README.md: c130a03735a15819c90821005e48160e222bda48
|
||||
README.zh.md: 6a713e64d102c86543e84becdabad2567d3bf29d
|
||||
|
||||
@@ -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. 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.
|
||||
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; 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 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. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. 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 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true']:last-child td {
|
||||
.table tbody tr[data-terminal-request-boundary='true'] td {
|
||||
/* Retain the lower half of the 16px boundary marker at the table's end. */
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
@@ -20,15 +20,16 @@ import type {
|
||||
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
||||
} from './trajectory-record.ts'
|
||||
import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts'
|
||||
import {
|
||||
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
||||
} from './trajectory-virtual-rows.ts'
|
||||
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
|
||||
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 COLLAPSED_SUMMARY_HEIGHT_PX = 20
|
||||
const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9
|
||||
const VIRTUAL_OVERSCAN_ROWS = 12
|
||||
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
|
||||
|
||||
@@ -125,6 +126,30 @@ interface TableRecord {
|
||||
collapsedSummaryKind?: 'turn' | 'assistant'
|
||||
}
|
||||
|
||||
interface VirtualRowStructure {
|
||||
height: number
|
||||
key: string
|
||||
}
|
||||
|
||||
function useStableVirtualRowStructure(
|
||||
rows: readonly TrajectoryVirtualRow<TableRecord>[],
|
||||
): readonly VirtualRowStructure[] {
|
||||
const cache = useRef<{
|
||||
rows: readonly TrajectoryVirtualRow<TableRecord>[]
|
||||
structure: readonly VirtualRowStructure[]
|
||||
}>({ rows: [], structure: [] })
|
||||
if (cache.current.rows === rows) return cache.current.structure
|
||||
const structure = cache.current.structure.length === rows.length
|
||||
&& rows.every((row, index) => {
|
||||
const previous = cache.current.structure[index]
|
||||
return previous?.key === row.key && previous.height === row.height
|
||||
})
|
||||
? cache.current.structure
|
||||
: rows.map(row => ({ key: row.key, height: row.height }))
|
||||
cache.current = { rows, structure }
|
||||
return structure
|
||||
}
|
||||
|
||||
type DetailTab =
|
||||
| 'system-prompt'
|
||||
| 'tools'
|
||||
@@ -319,6 +344,8 @@ export interface TrajectoryTableProps {
|
||||
requestNumbers?: readonly TrajectoryRequestNumber[]
|
||||
/** Grouped records in display order. */
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
/** In-flight cells whose content replaces the matching structural record index. */
|
||||
streamingCells?: readonly TrajectoryCellProps[]
|
||||
/** Record indexes emphasized by the active timeline focus. */
|
||||
timelineFocusIndexes?: ReadonlySet<number> | null
|
||||
/** Record indexes retained by the active live search, or null without a query. */
|
||||
@@ -426,12 +453,6 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
})
|
||||
}
|
||||
|
||||
function virtualRecordHeight(record: TableRecord, final: boolean): number {
|
||||
if (record.collapsedSummary !== undefined) return COLLAPSED_SUMMARY_HEIGHT_PX
|
||||
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>,
|
||||
@@ -1567,6 +1588,7 @@ function OverviewSection({
|
||||
export function TrajectoryTable({
|
||||
requestNumbers: sessionRequestNumbers,
|
||||
turns,
|
||||
streamingCells = [],
|
||||
timelineFocusIndexes = null,
|
||||
searchMatchIndexes = null,
|
||||
onSelectedIndexChange,
|
||||
@@ -1605,9 +1627,21 @@ export function TrajectoryTable({
|
||||
const [olderLoading, setOlderLoading] = useState(false)
|
||||
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
|
||||
const allRecords = useMemo(() => flattenRecords(turns), [turns])
|
||||
const selected = selectedRecordId === null
|
||||
const streamingCellsByIndex = useMemo(
|
||||
() => new Map(streamingCells.map(cell => [cell.index, cell])),
|
||||
[streamingCells],
|
||||
)
|
||||
const currentRecord = useCallback((record: TableRecord): TableRecord => {
|
||||
const cell = streamingCellsByIndex.get(record.cell.index)
|
||||
return cell === undefined ? record : { ...record, cell }
|
||||
}, [streamingCellsByIndex])
|
||||
const selectedTemplate = useMemo(() => selectedRecordId === null
|
||||
? undefined
|
||||
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId)
|
||||
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId),
|
||||
[allRecords, selectedRecordId])
|
||||
const selected = selectedTemplate === undefined
|
||||
? undefined
|
||||
: currentRecord(selectedTemplate)
|
||||
const selectedIndex = selected?.cell.index ?? null
|
||||
useEffect(() => {
|
||||
onSelectedIndexChange?.(selectedIndex)
|
||||
@@ -1625,38 +1659,72 @@ export function TrajectoryTable({
|
||||
? turnRecords
|
||||
: collapseAssistantRecords(turnRecords, collapsedAssistants)
|
||||
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
|
||||
const virtualizationEnabled = records.length > VIRTUALIZATION_THRESHOLD
|
||||
const projectedVirtualRows = useMemo(
|
||||
() => groupTrajectoryVirtualRows(records),
|
||||
[records],
|
||||
)
|
||||
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
|
||||
const virtualizationEnabled = hasOlderRecords
|
||||
|| records.length > VIRTUALIZATION_THRESHOLD
|
||||
const estimateVirtualRowSize = useCallback(
|
||||
(index: number) => virtualRowStructure[index]?.height ?? 30,
|
||||
[virtualRowStructure],
|
||||
)
|
||||
const getVirtualRowKey = useCallback(
|
||||
(index: number) => virtualRowStructure[index]?.key ?? index,
|
||||
[virtualRowStructure],
|
||||
)
|
||||
const getTableScrollElement = useCallback(() => tablePaneRef.current, [])
|
||||
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: virtualizationEnabled ? records.length : 0,
|
||||
count: virtualizationEnabled ? virtualRowStructure.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
|
||||
: `${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`
|
||||
},
|
||||
getScrollElement: () => tablePaneRef.current,
|
||||
estimateSize: estimateVirtualRowSize,
|
||||
getItemKey: getVirtualRowKey,
|
||||
getScrollElement: getTableScrollElement,
|
||||
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
|
||||
anchorTo: 'end',
|
||||
overscan: VIRTUAL_OVERSCAN_ROWS,
|
||||
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
|
||||
})
|
||||
const virtualRows = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
|
||||
const virtualTop = virtualRows[0]?.start ?? 0
|
||||
const virtualBottom = virtualRows.length === 0
|
||||
const virtualIndexByRecordId = useMemo(() => {
|
||||
const indexes = new Map<string, number>()
|
||||
for (const [virtualIndex, row] of projectedVirtualRows.entries()) {
|
||||
for (const entry of row.entries) {
|
||||
if (entry.record.collapsedSummary === undefined) {
|
||||
indexes.set(trajectoryRecordId(entry.record.cell), virtualIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}, [projectedVirtualRows])
|
||||
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
|
||||
const virtualTop = virtualItems[0]?.start ?? 0
|
||||
const virtualBottom = virtualItems.length === 0
|
||||
? 0
|
||||
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0))
|
||||
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
|
||||
const renderedRecords = virtualizationEnabled
|
||||
? virtualRows.flatMap((row) => {
|
||||
const record = records[row.index]
|
||||
return record === undefined ? [] : [{ record, position: row.index }]
|
||||
? virtualItems.flatMap((item) => {
|
||||
const row = projectedVirtualRows[item.index]
|
||||
if (row === undefined) return []
|
||||
return row.entries.map((entry, entryIndex) => ({
|
||||
record: currentRecord(entry.record),
|
||||
position: entry.logicalIndex,
|
||||
terminalRequestBoundary:
|
||||
entry.record.cell.requestOnly === true
|
||||
&& row.entries.at(-1)?.record.cell.requestOnly === true
|
||||
&& entryIndex === row.entries.length - 1,
|
||||
}))
|
||||
})
|
||||
: records.map((record, position) => ({ record, position }))
|
||||
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
|
||||
: records.map((record, position) => ({
|
||||
record: currentRecord(record),
|
||||
position,
|
||||
terminalRequestBoundary:
|
||||
record.cell.requestOnly === true && position === records.length - 1,
|
||||
}))
|
||||
const requestBoundaryRuns = useMemo(
|
||||
() => indexRequestBoundaryRuns(records),
|
||||
[records],
|
||||
)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.promptDetail
|
||||
: undefined
|
||||
@@ -1665,12 +1733,13 @@ export function TrajectoryTable({
|
||||
: undefined
|
||||
const promptSelected = selectedPrompt !== undefined
|
||||
const selectedState = selected === undefined ? undefined : stateOf(selected)
|
||||
const selectedRequestRecords = selectedRequest === null
|
||||
const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null
|
||||
? []
|
||||
: allRecords.filter(record =>
|
||||
record.turn === selectedRequest.turn
|
||||
&& record.group === selectedRequest.group,
|
||||
)
|
||||
), [allRecords, selectedRequest])
|
||||
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
|
||||
const selectedRequestAssistant = selectedRequestRecords.find(
|
||||
record => record.cell.kind === 'message',
|
||||
)
|
||||
@@ -1698,9 +1767,12 @@ export function TrajectoryTable({
|
||||
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
|
||||
record => record.cell.kind === 'subtool',
|
||||
).length
|
||||
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
|
||||
const selectedRequestResultTemplate = selectedRequestInfo?.resultSeq === undefined
|
||||
? selectedRequestAssistant
|
||||
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
|
||||
const selectedRequestResult = selectedRequestResultTemplate === undefined
|
||||
? undefined
|
||||
: currentRecord(selectedRequestResultTemplate)
|
||||
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
|
||||
selectedRequestAssistant === undefined
|
||||
? undefined
|
||||
@@ -1862,11 +1934,16 @@ export function TrajectoryTable({
|
||||
const position = records.findIndex(record =>
|
||||
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
|
||||
if (position === -1) return
|
||||
pendingScrollRecordId.current = null
|
||||
if (virtualizationEnabled) {
|
||||
rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' })
|
||||
const virtualIndex = virtualIndexByRecordId.get(id)
|
||||
if (virtualIndex === undefined) return
|
||||
pendingScrollRecordId.current = null
|
||||
followsTableTail.current = false
|
||||
rowVirtualizer.scrollToIndex(virtualIndex, { behavior: 'smooth', align: 'center' })
|
||||
return
|
||||
}
|
||||
pendingScrollRecordId.current = null
|
||||
followsTableTail.current = false
|
||||
const recordIndex = records[position]?.cell.index
|
||||
const row = recordIndex === undefined
|
||||
? null
|
||||
@@ -1875,7 +1952,7 @@ export function TrajectoryTable({
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [records, rowVirtualizer, virtualizationEnabled])
|
||||
}, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const focusedPositions = records.flatMap((record, position) =>
|
||||
@@ -1903,6 +1980,7 @@ export function TrajectoryTable({
|
||||
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
|
||||
if (target !== undefined && typeof target.scrollIntoView === 'function') {
|
||||
followsTableTail.current = false
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
|
||||
@@ -1910,24 +1988,38 @@ export function TrajectoryTable({
|
||||
}
|
||||
return
|
||||
}
|
||||
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
|
||||
let focusHeight = 0
|
||||
for (let position = first; position <= last; position++) {
|
||||
const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => {
|
||||
const record = records[position]
|
||||
if (record === undefined) continue
|
||||
focusHeight += virtualRecordHeight(
|
||||
record,
|
||||
position === records.length - 1,
|
||||
)
|
||||
}
|
||||
if (record === undefined) return []
|
||||
const virtualIndex = virtualIndexByRecordId.get(trajectoryRecordId(record.cell))
|
||||
return virtualIndex === undefined ? [] : [virtualIndex]
|
||||
}))].sort((left, right) => left - right)
|
||||
const firstVirtual = focusedVirtualIndexes.at(0)
|
||||
const lastVirtual = focusedVirtualIndexes.at(-1)
|
||||
if (firstVirtual === undefined || lastVirtual === undefined) return
|
||||
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
|
||||
const focusHeight = projectedVirtualRows
|
||||
.slice(firstVirtual, lastVirtual + 1)
|
||||
.reduce((height, row) => height + row.height, 0)
|
||||
followsTableTail.current = false
|
||||
rowVirtualizer.scrollToIndex(
|
||||
focusHeight > paneHeight ? first : focusedPositions[Math.floor((focusedPositions.length - 1) / 2)] ?? first,
|
||||
focusHeight > paneHeight
|
||||
? firstVirtual
|
||||
: focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)]
|
||||
?? firstVirtual,
|
||||
{
|
||||
behavior: 'smooth',
|
||||
align: focusHeight > paneHeight ? 'start' : 'center',
|
||||
},
|
||||
)
|
||||
}, [records, rowVirtualizer, timelineFocusIndexes, virtualizationEnabled])
|
||||
}, [
|
||||
projectedVirtualRows,
|
||||
records,
|
||||
rowVirtualizer,
|
||||
timelineFocusIndexes,
|
||||
virtualIndexByRecordId,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
const requestOlder = useCallback((pane: HTMLDivElement) => {
|
||||
if (
|
||||
!hasOlderRecords
|
||||
@@ -1954,7 +2046,9 @@ export function TrajectoryTable({
|
||||
if (pane === null) return
|
||||
const anchor = olderLoadAnchor.current
|
||||
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
|
||||
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
|
||||
if (!virtualizationEnabled) {
|
||||
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
|
||||
}
|
||||
olderLoadAnchor.current = null
|
||||
followsTableTail.current = false
|
||||
return
|
||||
@@ -1971,7 +2065,13 @@ export function TrajectoryTable({
|
||||
if (!followsTableTail.current) return
|
||||
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
|
||||
else pane.scrollTop = pane.scrollHeight
|
||||
}, [historyLoading, historyStartSeq, rowVirtualizer, turns, virtualizationEnabled])
|
||||
}, [
|
||||
historyLoading,
|
||||
historyStartSeq,
|
||||
rowVirtualizer,
|
||||
virtualRowStructure,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
|
||||
const loadingLabel = olderLoading
|
||||
? 'Loading earlier history…'
|
||||
@@ -1983,6 +2083,7 @@ export function TrajectoryTable({
|
||||
<div
|
||||
ref={tablePaneRef}
|
||||
className={css.tablePane}
|
||||
data-trajectory-scroll=""
|
||||
onScroll={(event) => {
|
||||
const pane = event.currentTarget
|
||||
followsTableTail.current =
|
||||
@@ -2005,6 +2106,7 @@ export function TrajectoryTable({
|
||||
<table
|
||||
className={css.table}
|
||||
data-scroll-ready={tableScrollReady || undefined}
|
||||
aria-rowcount={records.length}
|
||||
>
|
||||
<colgroup>
|
||||
<col className={css.eventColumn} />
|
||||
@@ -2021,7 +2123,7 @@ export function TrajectoryTable({
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
{renderedRecords.map(({ record, position }) => {
|
||||
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
|
||||
const displayText = recordDisplayText(record.cell)
|
||||
const toolCallOnly = isToolCallOnly(record.cell)
|
||||
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
||||
@@ -2059,8 +2161,9 @@ export function TrajectoryTable({
|
||||
: activeTurn === record.turn
|
||||
return (
|
||||
<tr
|
||||
key={`${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`}
|
||||
key={trajectoryVirtualRecordKey(record)}
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: isRequestOnly
|
||||
@@ -2068,11 +2171,13 @@ 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-trajectory-row-key={trajectoryVirtualRecordKey(record)}
|
||||
data-virtual-position={virtualizationEnabled ? position : undefined}
|
||||
data-record-index={!isCollapsedSummary && !isRequestOnly
|
||||
? record.cell.index
|
||||
: undefined}
|
||||
data-request-only={isRequestOnly || undefined}
|
||||
data-terminal-request-boundary={terminalRequestBoundary || undefined}
|
||||
data-group-start={record.groupStart || undefined}
|
||||
data-turn-start={record.turnStart || undefined}
|
||||
data-error={record.cell.isError || undefined}
|
||||
|
||||
@@ -373,10 +373,6 @@ export function TrajectoryView({
|
||||
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
|
||||
@@ -401,6 +397,12 @@ export function TrajectoryView({
|
||||
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
|
||||
[finalized.lastIndex, partial],
|
||||
)
|
||||
const streamingCells = useMemo(
|
||||
() => partialSearchTurns.flatMap(turn =>
|
||||
turn.groups.flatMap(group => group.cells),
|
||||
),
|
||||
[partialSearchTurns],
|
||||
)
|
||||
const partialSearchMatches = useMemo(
|
||||
() => searchMatches(partialSearchTurns, searchQuery),
|
||||
[partialSearchTurns, searchQuery],
|
||||
@@ -426,8 +428,22 @@ export function TrajectoryView({
|
||||
setTimelineSelection(null)
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
|
||||
setTimelineSelection(range === null ? null : {
|
||||
branchKey: currentBranch.key,
|
||||
range,
|
||||
})
|
||||
}, [currentBranch.key])
|
||||
const handleTimelineRecordSelect = useCallback((index: number) => {
|
||||
setTimelineSelection(null)
|
||||
setTimelineRecordSelection({ index })
|
||||
setSelectedTimelineIndex(index)
|
||||
}, [])
|
||||
const handleTimelineRecordFocus = useCallback((index: number) => {
|
||||
setTimelineRecordFocus({ index })
|
||||
}, [])
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
() => timelineTurns
|
||||
.filter(turn =>
|
||||
turn.turn !== null
|
||||
&&
|
||||
@@ -438,13 +454,13 @@ export function TrajectoryView({
|
||||
0,
|
||||
) > 1)
|
||||
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
|
||||
[turns],
|
||||
[timelineTurns],
|
||||
)
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||
const collapsibleAssistantIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const turn of turns) {
|
||||
for (const turn of timelineTurns) {
|
||||
const cells = turn.groups.flatMap(group => group.cells)
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
@@ -456,7 +472,7 @@ export function TrajectoryView({
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [turns])
|
||||
}, [timelineTurns])
|
||||
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||
|
||||
@@ -537,26 +553,16 @@ export function TrajectoryView({
|
||||
onLoadEarlier={loadEarlierHistory}
|
||||
selectedIndex={selectedTimelineIndex}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : {
|
||||
branchKey: currentBranch.key,
|
||||
range,
|
||||
})
|
||||
}}
|
||||
onRecordSelect={(index) => {
|
||||
setTimelineSelection(null)
|
||||
setTimelineRecordSelection({ index })
|
||||
setSelectedTimelineIndex(index)
|
||||
}}
|
||||
onRecordFocus={(index) => {
|
||||
setTimelineRecordFocus({ index })
|
||||
}}
|
||||
onRangeChange={handleTimelineRangeChange}
|
||||
onRecordSelect={handleTimelineRecordSelect}
|
||||
onRecordFocus={handleTimelineRecordFocus}
|
||||
/>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.key}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
turns={timelineTurns}
|
||||
streamingCells={streamingCells}
|
||||
timelineFocusIndexes={timelineFocusIndexes}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Pure projection from trajectory records to measurable virtual ledger rows. */
|
||||
|
||||
import type { TrajectoryCellProps } from './trajectory-record.ts'
|
||||
import { trajectoryRecordId } from './trajectory-record.ts'
|
||||
|
||||
const CONTENT_ROW_HEIGHT = 30
|
||||
const COLLAPSED_SUMMARY_HEIGHT = 20
|
||||
const TERMINAL_BOUNDARY_HEIGHT = 9
|
||||
|
||||
/** Minimal record shape required by the trajectory virtual-row projection. */
|
||||
export interface VirtualizableTrajectoryRecord {
|
||||
cell: TrajectoryCellProps
|
||||
collapsedSummaryKind?: 'turn' | 'assistant'
|
||||
}
|
||||
|
||||
/** One logical record retained inside a measurable virtual row. */
|
||||
export interface TrajectoryVirtualRowEntry<T extends VirtualizableTrajectoryRecord> {
|
||||
logicalIndex: number
|
||||
record: T
|
||||
}
|
||||
|
||||
/** One virtualizer item, which may carry zero-height request boundaries. */
|
||||
export interface TrajectoryVirtualRow<T extends VirtualizableTrajectoryRecord> {
|
||||
entries: readonly TrajectoryVirtualRowEntry<T>[]
|
||||
height: number
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the DOM-safe row identity shared by React, the virtualizer, and
|
||||
* browser scroll contracts.
|
||||
* @param record - Display record whose identity is required.
|
||||
* @returns Stable record identity with a suffix for synthetic fold summaries.
|
||||
*/
|
||||
export function trajectoryVirtualRecordKey(
|
||||
record: VirtualizableTrajectoryRecord,
|
||||
): string {
|
||||
const identity = encodeURIComponent(trajectoryRecordId(record.cell))
|
||||
return record.collapsedSummaryKind === undefined
|
||||
? identity
|
||||
: `${identity}\u0000summary\u0000${record.collapsedSummaryKind}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach separator-only records to the next content row so the virtualizer
|
||||
* never owns a zero-height item. A terminal separator retains its CSS-owned
|
||||
* lower-marker clearance as a standalone item.
|
||||
* @param records - Final search/fold projection in ledger order.
|
||||
* @returns Measurable virtual rows with original logical positions retained.
|
||||
*/
|
||||
export function groupTrajectoryVirtualRows<T extends VirtualizableTrajectoryRecord>(
|
||||
records: readonly T[],
|
||||
): readonly TrajectoryVirtualRow<T>[] {
|
||||
const rows: TrajectoryVirtualRow<T>[] = []
|
||||
let pending: TrajectoryVirtualRowEntry<T>[] = []
|
||||
|
||||
for (const [logicalIndex, record] of records.entries()) {
|
||||
const entry = { logicalIndex, record }
|
||||
if (record.cell.requestOnly === true) {
|
||||
pending.push(entry)
|
||||
continue
|
||||
}
|
||||
const entries = [...pending, entry]
|
||||
pending = []
|
||||
rows.push({
|
||||
entries,
|
||||
height: record.collapsedSummaryKind === undefined
|
||||
? CONTENT_ROW_HEIGHT
|
||||
: COLLAPSED_SUMMARY_HEIGHT,
|
||||
key: trajectoryVirtualRecordKey(record),
|
||||
})
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
rows.push({
|
||||
entries: pending,
|
||||
height: TERMINAL_BOUNDARY_HEIGHT,
|
||||
key: pending.map(candidate => trajectoryVirtualRecordKey(candidate.record)).join('|'),
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -323,7 +323,7 @@ describe('TrajectoryTable', () => {
|
||||
expect(tablePane.scrollTop).toBe(20)
|
||||
})
|
||||
|
||||
it('loads one older page at the top and preserves the visible anchor', async () => {
|
||||
it('preserves the visible anchor when the last older page disables virtualization', async () => {
|
||||
let resolveOlder: ((advanced: boolean) => void) | undefined
|
||||
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
|
||||
const onLoadOlder = vi.fn(() => older)
|
||||
@@ -362,7 +362,6 @@ describe('TrajectoryTable', () => {
|
||||
}, ...TURNS]}
|
||||
{...FOLD_PROPS}
|
||||
historyStartSeq={0}
|
||||
hasOlderRecords
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
)
|
||||
@@ -384,6 +383,21 @@ describe('TrajectoryTable', () => {
|
||||
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
const view = render(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('mounts only the visible window for a long ledger', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
const scrollTo = vi.fn()
|
||||
@@ -409,6 +423,9 @@ describe('TrajectoryTable', () => {
|
||||
})
|
||||
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
|
||||
.toBeLessThan(cells.length)
|
||||
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500')
|
||||
expect(view.container.querySelector('tr[data-trajectory-row-key]')
|
||||
?.getAttribute('aria-rowindex')).toBe('1')
|
||||
expect(scrollTo).toHaveBeenCalled()
|
||||
expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
|
||||
expect(screen.getByText('Context 1')).toBeTruthy()
|
||||
@@ -426,6 +443,47 @@ describe('TrajectoryTable', () => {
|
||||
expect(screen.queryByText('Context 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not re-scroll a virtual ledger when streaming only changes row content', 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,
|
||||
sourceSeq: index + 1,
|
||||
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.querySelector('tr[data-virtual-position]')).toBeTruthy()
|
||||
})
|
||||
scrollTo.mockClear()
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={turns}
|
||||
streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Context 1 streaming update')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
|
||||
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal file
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Measurable virtual-row grouping and durable identity contracts. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
|
||||
import {
|
||||
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
||||
type VirtualizableTrajectoryRecord,
|
||||
} from '../src/client/trajectory-virtual-rows.ts'
|
||||
|
||||
function record(
|
||||
index: number,
|
||||
cell: Partial<TrajectoryCellProps> = {},
|
||||
collapsedSummaryKind?: 'turn' | 'assistant',
|
||||
): VirtualizableTrajectoryRecord {
|
||||
return {
|
||||
cell: {
|
||||
index,
|
||||
kind: 'message',
|
||||
text: `record ${index}`,
|
||||
timeSeconds: 0,
|
||||
...cell,
|
||||
},
|
||||
...(collapsedSummaryKind === undefined ? {} : { collapsedSummaryKind }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory virtual rows', () => {
|
||||
it('groups zero-height request boundaries with the following content row', () => {
|
||||
const first = record(1, { requestOnly: true, sourceSeq: 10 })
|
||||
const second = record(2, { requestOnly: true, sourceSeq: 11 })
|
||||
const content = record(3, { sourceSeq: 12 })
|
||||
|
||||
expect(groupTrajectoryVirtualRows([first, second, content])).toEqual([{
|
||||
entries: [
|
||||
{ logicalIndex: 0, record: first },
|
||||
{ logicalIndex: 1, record: second },
|
||||
{ logicalIndex: 2, record: content },
|
||||
],
|
||||
height: 30,
|
||||
key: trajectoryVirtualRecordKey(content),
|
||||
}])
|
||||
})
|
||||
|
||||
it('retains terminal request-boundary clearance as a measurable row', () => {
|
||||
const content = record(1, { sourceSeq: 10 })
|
||||
const boundary = record(2, { requestOnly: true, sourceSeq: 11 })
|
||||
const rows = groupTrajectoryVirtualRows([content, boundary])
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[1]).toEqual({
|
||||
entries: [{ logicalIndex: 1, record: boundary }],
|
||||
height: 9,
|
||||
key: trajectoryVirtualRecordKey(boundary),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the rendered collapsed-summary height', () => {
|
||||
const summary = record(1, { sourceSeq: 10 }, 'turn')
|
||||
|
||||
expect(groupTrajectoryVirtualRows([summary])[0]?.height).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps an existing row key stable when older history is prepended', () => {
|
||||
const existing = record(2, { sourceSeq: 100 })
|
||||
const prepended = record(1, { sourceSeq: 10 })
|
||||
|
||||
const before = groupTrajectoryVirtualRows([existing])[0]?.key
|
||||
const after = groupTrajectoryVirtualRows([prepended, existing])[1]?.key
|
||||
|
||||
expect(after).toBe(before)
|
||||
})
|
||||
|
||||
it('keeps the content key when a request boundary joins its row', () => {
|
||||
const content = record(2, { sourceSeq: 100 })
|
||||
const boundary = record(1, { requestOnly: true, sourceSeq: 99 })
|
||||
|
||||
expect(groupTrajectoryVirtualRows([boundary, content])[0]?.key)
|
||||
.toBe(groupTrajectoryVirtualRows([content])[0]?.key)
|
||||
})
|
||||
|
||||
it('distinguishes a folded summary from its source record', () => {
|
||||
const source = record(1, { sourceSeq: 10 })
|
||||
const summary = record(1, { sourceSeq: 10 }, 'assistant')
|
||||
|
||||
expect(trajectoryVirtualRecordKey(summary)).not.toBe(trajectoryVirtualRecordKey(source))
|
||||
})
|
||||
|
||||
it('exposes a DOM-safe semantic key', () => {
|
||||
const source = record(1, { callId: 'call with spaces/and?punctuation' })
|
||||
|
||||
expect(trajectoryVirtualRecordKey(source)).toBe(
|
||||
'message%00call%00call%20with%20spaces%2Fand%3Fpunctuation',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user