fix(trajectory): bound work during long streams

This commit is contained in:
_Kerman
2026-08-04 20:37:33 +08:00
parent 5372f56280
commit 92de5bb6d4
20 changed files with 1145 additions and 114 deletions

View File

@@ -78,28 +78,61 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']):
function foldContexts(
events: readonly SessionEvent[],
baseSeq: number,
): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const surface = new SurfaceManager(replay, baseSeq)
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
const originalNodes = () => surface.nodes.map((seq) => {
const original = originalSeqs[seq]
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
return original
})
for (const event of events) {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
replay.push(event)
const rebasedSeq = replay.length
const {
sourceEventSeqs: rawSources,
...eventWithoutSources
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
const rebased = rebasedSeqByOriginal.get(seq)
return rebased === undefined ? [] : [rebased]
})
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
? undefined
: mappedSourceEventSeqs
const surfaceOp = event.surfaceOp === 'append'
? event.surfaceOp
: {
...event.surfaceOp,
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
}
originalSeqs.push(event.seq)
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
replay.push({
...eventWithoutSources,
seq: rebasedSeq,
surfaceOp,
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
} as SessionEvent)
}
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
@@ -327,6 +360,7 @@ export function projectConversationHistory(
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const baseSeq = events[0]?.seq ?? 0
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
@@ -396,7 +430,7 @@ export function projectConversationHistory(
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = events[seq - baseSeq]
const event = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
@@ -422,7 +456,7 @@ export function projectConversationHistory(
}]
} else {
try {
contexts = foldContexts(events, baseSeq).map((context): ConversationContext => {
contexts = foldContexts(events).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
@@ -435,7 +469,7 @@ export function projectConversationHistory(
nodes,
}
}
const originEvent = events[context.originSeq - baseSeq]
const originEvent = eventsBySeq.get(context.originSeq)
return {
id: context.generation,
parentId: context.generation - 1,

View File

@@ -6,7 +6,9 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import {
compactHistoryInspectionEntries, createHistoryInspection,
} from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
@@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean {
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: readonly HistoryEntry[] = []
private entries: HistoryEntry[] = []
private inspectionEntries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
@@ -135,6 +138,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
@@ -250,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace {
return
}
this.entries = [...older, ...this.entries]
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
@@ -281,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
@@ -314,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace {
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries = [...this.entries, entry]
this.entries.push(entry)
this.inspectionEntries = [...this.inspectionEntries, entry]
if (entry.event.type === 'assistant/message') {
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
}
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
@@ -326,7 +336,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
return false
}
const base = this.currentInspection()
@@ -345,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
entries: this.inspectionEntries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
@@ -410,8 +420,8 @@ export class SessionHistorySource implements SessionHistoryFace {
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
if (this.inspectionCache?.entries !== this.inspectionEntries) {
const entries = this.inspectionEntries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),

View File

@@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
const event = entry.event
if (event.type !== 'assistant/chunk') return false
switch (event.data.chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return event.data.chunk.text !== ''
case 'tool-call-delta':
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
default:
return false
}
}
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
@@ -19,6 +37,47 @@ export interface SessionHistoryInspection {
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
* Remove completed-step token payloads that no inspection projection reads.
* The first visible token preserves timing, usage chunks preserve accounting,
* and unfinished steps retain every chunk for live or interrupted content.
* @param entries - Contiguous raw history entries in sequence order.
* @returns A projection-equivalent, usually much smaller entry ledger.
*/
export function compactHistoryInspectionEntries(
entries: readonly HistoryEntry[],
): readonly HistoryEntry[] {
const completedSteps = new Set<string>()
for (const { event } of entries) {
if (event.type === 'assistant/message') {
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
}
}
const firstTokenSteps = new Set<string>()
const compacted: HistoryEntry[] = []
let changed = false
for (const entry of entries) {
const event = entry.event
if (event.type !== 'assistant/chunk') {
compacted.push(entry)
continue
}
const key = assistantStepKey(event.data.turn, event.data.step)
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
compacted.push(entry)
continue
}
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
firstTokenSteps.add(key)
compacted.push(entry)
} else {
changed = true
}
}
return changed ? compacted : entries
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots

View File

@@ -2,6 +2,8 @@ import { createMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
@@ -122,4 +124,35 @@ describe('projectConversationHistory', () => {
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),
ev.stepStart(1, 1, 0),
ev.chunkStart(2, 1),
ev.chunkText(3, 1, ''),
ev.chunkText(4, 1, 'first'),
ev.chunkText(5, 1, ' discarded'),
at(6, { type: 'assistant/chunk', data: {
turn: 1,
step: 0,
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
} }),
ev.assistant(7, 1, 'first discarded'),
ev.compactSummary(8, 'summary', 0, 7),
ev.compactCheckpoint(9, 8, 0, 7),
ev.stepStart(10, 2, 0),
ev.chunkStart(11, 2),
ev.chunkText(12, 2, 'interrupted'),
ev.turnEnd(13, 2, 'aborted'),
]
const raw = events.map(event => ({ event }))
const compacted = compactHistoryInspectionEntries(raw)
expect(compacted.map(entry => entry.event.seq)).toEqual([
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
])
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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。
## 模型体验

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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', {

View 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',
)
})
})