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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
|
||||
2026-07-27-trajectory-inspection-ledger.md: 0bab35eaba2c0c741c2340532fd0e37f8befd0b1
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: 4bcbe873d8166756cbdd87a4f7a28cc6bf3b46b1
|
||||
2026-07-27-trajectory-inspection-ledger.md: fb2d9aaf07b7f3c88f7e870b309541b6abbb8a27
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: 6ef6cdabe0a67b7cc8b8d9c2e4a9f5630acf3b73
|
||||
@@ -21,11 +21,11 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
|
||||
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
|
||||
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
|
||||
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
|
||||
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Fixed row estimates and virtual spacer rows preserve the loaded scroll range, while selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. Prepending that page restores the prior visible anchor instead of jumping to the new top; the raw window base sequence detects the prepend even when a page adds no surface-visible node.
|
||||
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. The raw window base sequence detects a prepend even when a page adds no surface-visible node.
|
||||
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
|
||||
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows.
|
||||
- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections.
|
||||
- History folding passes the loaded window's absolute starting sequence into the canonical surface manager. Structural events therefore rebuild only the entries that are present instead of materializing synthetic events for every unloaded sequence before the window.
|
||||
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
|
||||
- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. Before those rebuilds, the inspection ledger drops completed-step token payloads that no projection reads while retaining the first visible token for timing, every usage chunk for accounting, and every chunk from unfinished or interrupted steps; the independent history source retains the raw entries.
|
||||
- History folding rebases only the loaded surface events into a compact contiguous input for the canonical surface manager, then maps its nodes back to absolute session sequences. Structural events therefore retain canonical replacement validation without replaying token chunks or materializing synthetic events for unloaded sequences.
|
||||
- Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay.
|
||||
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.
|
||||
|
||||
@@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
|
||||
|
||||
## Consequences
|
||||
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, and incremental partial projection removes loaded-history length from ordinary token-frame work; structural rebuilds remain linear in the loaded window rather than its absolute tail sequence. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition.
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, incremental partial projection removes loaded-history length from ordinary token-frame work, and completed-step chunk compaction makes structural rebuilds proportional to inspection-relevant entries rather than the raw token count. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition.
|
||||
@@ -21,11 +21,11 @@ Status: implemented
|
||||
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
|
||||
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
|
||||
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
|
||||
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部;原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。
|
||||
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。
|
||||
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
|
||||
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。
|
||||
- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。
|
||||
- 历史折叠会把已加载窗口的绝对起始序号传给规范 surface manager。因此,结构事件只重建实际存在的条目,而不会为窗口之前每个尚未加载的序号实体化合成事件。
|
||||
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
|
||||
- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。
|
||||
- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。
|
||||
- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。
|
||||
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
|
||||
|
||||
@@ -53,4 +53,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影则让普通 token 帧的工作量不再随已加载历史长度增长;结构重建的复杂度仍与已加载窗口线性相关,而非与尾部的绝对序号线性相关。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表契约锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
|
||||
@@ -0,0 +1,235 @@
|
||||
// Synthetic long-chat history for browser behavior contracts. The fixture is
|
||||
// generated through Session so pagination exercises the same event shapes as
|
||||
// persisted conversations, while unique markers identify semantic rows
|
||||
// without depending on CSS-module names or virtualizer DOM positions.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
// Carries the session/title event declaration into this fixture builder.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Options for one deterministic long-chat fixture. */
|
||||
export interface ChatScrollFixtureOptions {
|
||||
/** Marker namespace, used when two sessions share one browser world. */
|
||||
readonly markerPrefix: string
|
||||
/** Searchable title projected into the sidebar. */
|
||||
readonly title: string
|
||||
/** Number of closed turns to generate. */
|
||||
readonly turns?: number
|
||||
}
|
||||
|
||||
/** Semantic marker helpers returned with a generated fixture. */
|
||||
interface ChatScrollMarkers {
|
||||
/** Marker painted in the human message for a turn. */
|
||||
user(turn: number): string
|
||||
/** Marker painted in the final assistant message for a turn. */
|
||||
assistant(turn: number): string
|
||||
/** Marker painted in one seeded bash call and result. */
|
||||
tool(turn: number, index: number): string
|
||||
}
|
||||
|
||||
/** Generated JSONL plus the stable facts browser scenarios assert. */
|
||||
export interface ChatScrollFixture {
|
||||
readonly log: string
|
||||
readonly markers: ChatScrollMarkers
|
||||
readonly title: string
|
||||
readonly turns: number
|
||||
}
|
||||
|
||||
const DEFAULT_TURNS = 88
|
||||
const TOOL_INTERVAL = 8
|
||||
const CODE_INTERVAL = 11
|
||||
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
function suffix(turn: number): string {
|
||||
return String(turn).padStart(3, '0')
|
||||
}
|
||||
|
||||
function markerHelpers(prefix: string): ChatScrollMarkers {
|
||||
return {
|
||||
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
|
||||
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
|
||||
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function appendRequestHeader(session: Session, turn: number, step: number): void {
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
|
||||
},
|
||||
reason: turn === 1 && step === 1 ? 'initial' : 'change',
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: text(body),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: {
|
||||
inputTokens: 2_000 + turn * 7,
|
||||
outputTokens: 180 + step * 20,
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function codeBlock(turn: number): string {
|
||||
if (turn % CODE_INTERVAL !== 0) return ''
|
||||
const lines = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
|
||||
)
|
||||
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
markers: ChatScrollMarkers,
|
||||
turn: number,
|
||||
): void {
|
||||
const calls = [1, 2].map((index) => {
|
||||
const marker = markers.tool(turn, index)
|
||||
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${marker}\\n'`,
|
||||
description: marker,
|
||||
})
|
||||
return { args, callId, marker }
|
||||
})
|
||||
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
|
||||
...calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})),
|
||||
],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(Array.from(
|
||||
{ length: 12 },
|
||||
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
|
||||
).join('\n')),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureLog(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: '{{cwd}}',
|
||||
delegationDepth: 0,
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-page conversation with prose, fenced code, and paired bash
|
||||
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
|
||||
* the seed before the browser observes it.
|
||||
* @param options - Fixture identity and optional turn count.
|
||||
* @returns Canonical JSONL and semantic marker helpers.
|
||||
*/
|
||||
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
|
||||
const turns = options.turns ?? DEFAULT_TURNS
|
||||
const markers = markerHelpers(options.markerPrefix)
|
||||
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
|
||||
+ 'Keep the visible message stable while history, tools, and new output change around it.',
|
||||
),
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (turn === 1) {
|
||||
session.append('session/title', {
|
||||
title: options.title,
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
appendRequestHeader(session, turn, 1)
|
||||
if (turn % TOOL_INTERVAL === 0) {
|
||||
appendToolStep(session, markers, turn)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('step/start', { turn, step: 2 })
|
||||
appendRequestHeader(session, turn, 2)
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
2,
|
||||
`${markers.assistant(turn)} Both tool results are accounted for. `
|
||||
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 2 })
|
||||
} else {
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
1,
|
||||
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
|
||||
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
|
||||
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
|
||||
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
}
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
return { log: fixtureLog(session), markers, title: options.title, turns }
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Browser contract for the tail-paged, virtualized Trajectory ledger. The
|
||||
// scenario proves that semantic row identity survives an older-page prepend,
|
||||
// DOM mounting stays bounded, and every scroll range remains reachable.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'trajectory-virtualization-e2e'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TRAJECTORY_VIRTUAL',
|
||||
title: 'TRAJECTORY_VIRTUAL long ledger',
|
||||
turns: 88,
|
||||
})
|
||||
const MAX_MOUNTED_ROWS = 160
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
|
||||
const STREAM_TEXT = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
).join('') + STREAM_MARKER
|
||||
|
||||
const STREAM_CHUNKS: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from({ length: 80 }, (_, index): StreamChunk => ({
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text: `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
})),
|
||||
{ type: 'text-delta', index: 0, text: STREAM_MARKER },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } },
|
||||
{ type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly clientHeight: number
|
||||
readonly scrollHeight: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface RowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function openTrajectory(page: Page): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
const pane = page.locator('[data-trajectory-scroll]')
|
||||
await pane.waitFor({ timeout: 30_000 })
|
||||
await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]')
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function logicalRows(page: Page): Promise<number> {
|
||||
const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount')
|
||||
if (raw === null || !/^\d+$/.test(raw)) {
|
||||
throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return Number(raw)
|
||||
}
|
||||
|
||||
async function mountedRows(page: Page): Promise<number> {
|
||||
return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count()
|
||||
}
|
||||
|
||||
async function geometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate(host => ({
|
||||
clientHeight: host.clientHeight,
|
||||
scrollHeight: host.scrollHeight,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
}
|
||||
|
||||
async function scrollToRatio(page: Page, ratio: number): Promise<void> {
|
||||
await page.locator('[data-trajectory-scroll]').evaluate((host, value) => {
|
||||
const maximum = Math.max(0, host.scrollHeight - host.clientHeight)
|
||||
host.scrollTop = Math.round(maximum * value)
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
}, ratio)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function firstVisibleRow(page: Page): Promise<RowAnchor> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host) => {
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find((candidate) => {
|
||||
const box = candidate.getBoundingClientRect()
|
||||
return candidate.dataset.requestOnly !== 'true'
|
||||
&& box.bottom > hostBox.top
|
||||
&& box.top < hostBox.bottom
|
||||
})
|
||||
const key = row?.dataset.trajectoryRowKey
|
||||
if (row === undefined || key === undefined) {
|
||||
throw new Error('trajectory scrollport has no visible semantic row')
|
||||
}
|
||||
return { key, top: row.getBoundingClientRect().top - hostBox.top }
|
||||
})
|
||||
}
|
||||
|
||||
async function rowTop(page: Page, key: string): Promise<number | null> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey)
|
||||
return row === undefined
|
||||
? null
|
||||
: row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function loadToFirstTurn(page: Page): Promise<void> {
|
||||
const marker = FIXTURE.markers.user(1)
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await scrollToRatio(page, 0)
|
||||
if (await page.getByText(marker, { exact: false }).count() > 0) return
|
||||
const before = await logicalRows(page)
|
||||
await expect.poll(async () => ({
|
||||
marker: await page.getByText(marker, { exact: false }).count() > 0,
|
||||
rows: await logicalRows(page),
|
||||
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
|
||||
}
|
||||
throw new Error('trajectory did not reach the first turn after twelve older-page requests')
|
||||
}
|
||||
|
||||
describe('web e2e: Trajectory virtualization over tail-paged history', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let replayDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
|
||||
const replayFixture = join(replayDir, 'session.jsonl')
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayFixture, FIXTURE.log)
|
||||
await writeFile(replayOverride, JSON.stringify([{
|
||||
kind: 'chunks',
|
||||
chunks: STREAM_CHUNKS,
|
||||
} satisfies ReplayEntry]))
|
||||
scaffold = await launchWebScaffold({
|
||||
paceMs: 10,
|
||||
replayFixture,
|
||||
replayOverride,
|
||||
})
|
||||
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization'))
|
||||
await openSeed(page)
|
||||
|
||||
let held = false
|
||||
let releaseHistory: () => void = () => {}
|
||||
let finishHeldRequest: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
|
||||
await page.route('**/api/session.history', async (route) => {
|
||||
const request = route.request().postDataJSON() as {
|
||||
method?: string
|
||||
payload?: { beforeSeq?: number }
|
||||
}
|
||||
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
||||
held = true
|
||||
await gate
|
||||
try {
|
||||
await route.continue()
|
||||
} finally {
|
||||
finishHeldRequest()
|
||||
}
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
try {
|
||||
await openTrajectory(page)
|
||||
const initialRows = await logicalRows(page)
|
||||
expect(initialRows).toBeGreaterThan(0)
|
||||
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await scrollToRatio(page, 0)
|
||||
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
|
||||
const anchor = await firstVisibleRow(page)
|
||||
const selectedRow = page.locator(
|
||||
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
|
||||
)
|
||||
await selectedRow.click()
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
|
||||
await nextPaint(page)
|
||||
await expect.poll(async () => {
|
||||
const top = await rowTop(page, anchor.key)
|
||||
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
|
||||
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await loadToFirstTurn(page)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
const fullRows = await logicalRows(page)
|
||||
|
||||
await scrollToRatio(page, 0.5)
|
||||
const middle = await geometry(page)
|
||||
const maximum = middle.scrollHeight - middle.clientHeight
|
||||
expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25)
|
||||
expect(middle.scrollTop).toBeLessThan(maximum * 0.75)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect(await mountedRows(page)).toBeLessThan(fullRows)
|
||||
|
||||
await scrollToRatio(page, 1)
|
||||
await expect.poll(async () => {
|
||||
const value = await geometry(page)
|
||||
return value.scrollHeight - value.clientHeight - value.scrollTop
|
||||
}, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
const trajectoryScroll = page.locator('[data-trajectory-scroll]')
|
||||
await trajectoryScroll.evaluate((host) => {
|
||||
const measuredWindow = window as Window & { __trajectoryScrollCalls?: number }
|
||||
measuredWindow.__trajectoryScrollCalls = 0
|
||||
const original = host.scrollTo.bind(host)
|
||||
const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => {
|
||||
measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1
|
||||
Reflect.apply(original, host, args)
|
||||
}
|
||||
host.scrollTo = trackedScrollTo as typeof host.scrollTo
|
||||
})
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('Stream one deterministic response while Trajectory remains visible.')
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
const streamingScrollCalls = await trajectoryScroll.evaluate(() => {
|
||||
return (window as Window & { __trajectoryScrollCalls?: number })
|
||||
.__trajectoryScrollCalls ?? 0
|
||||
})
|
||||
expect(streamingScrollCalls).toBeLessThanOrEqual(5)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({ pageErrors: [], warnings: [] })
|
||||
} finally {
|
||||
releaseHistory()
|
||||
if (held) await heldRequestFinished
|
||||
await page.unroute('**/api/session.history')
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -31,6 +31,8 @@
|
||||
"tests/plan-review.e2e.ts",
|
||||
"tests/steering.e2e.ts",
|
||||
"tests/navigation-panes.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
"tests/trajectory-virtualization.e2e.ts",
|
||||
"tests/lifecycle-chrome.e2e.ts",
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,8 @@
|
||||
"apps/web/tests/plan-review.e2e.ts",
|
||||
"apps/web/tests/steering.e2e.ts",
|
||||
"apps/web/tests/navigation-panes.e2e.ts",
|
||||
"apps/web/tests/chat-scroll-fixture.ts",
|
||||
"apps/web/tests/trajectory-virtualization.e2e.ts",
|
||||
"apps/web/tests/lifecycle-chrome.e2e.ts",
|
||||
"apps/web/tests/details-session-lifecycle.e2e.ts",
|
||||
"apps/web/tests/settings-chrome.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user