fix(trajectory): preserve state across history prepends

This commit is contained in:
_Kerman
2026-08-04 16:53:52 +08:00
parent 327505f5c6
commit 13fed3721f
23 changed files with 425 additions and 84 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: 6447baaa7a0e949ba7cb357b3741a3b5c11851e6
2026-07-27-trajectory-inspection-ledger.zh.md: 3c17b5f3bddeac3d27e7ef07b4aa54cea05fa3d3
2026-07-27-trajectory-inspection-ledger.md: 0bab35eaba2c0c741c2340532fd0e37f8befd0b1
2026-07-27-trajectory-inspection-ledger.zh.md: 4bcbe873d8166756cbdd87a4f7a28cc6bf3b46b1

View File

@@ -21,10 +21,10 @@ 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 their position in the projection 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.
- 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.
- 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, and Overview projection. A frame appends only the current partial Assistant cells; text and reasoning deltas do not re-fold the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections.
- 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.
- 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, 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, 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.

View File

@@ -21,10 +21,10 @@ Status: implemented
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture测试前置数据有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后轨迹视图内部会打开检查器其标签页和概览区域随实体类型变化Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按记录在投影中的位置定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部;原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页
- 移除独立的 waterfall瀑布式事件标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport不会改变该选区。聚焦后Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。
- token 流式输出会复用已完成历史的检查结果、布局、请求编号Overview 投影。每个帧只追加当前未完成助手的单元格文本与推理reasoning增量不会重新折叠已加载前缀而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。
- token 流式输出会复用已完成历史的检查结果、布局、请求编号Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容文本与推理reasoning增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。
- 历史折叠会把已加载窗口的绝对起始序号传给规范 surface manager。因此结构事件只重建实际存在的条目而不会为窗口之前每个尚未加载的序号实体化合成事件。
- 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 帧的工作量不再随已加载历史长度增长;结构重建的复杂度仍与已加载窗口线性相关,而非与尾部的绝对序号线性相关。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
session.md: 6369c956df03c0d786db696c208b000300d5bfbc
session.zh.md: c39382b7e6c9b14f91c311cc80526a6fd8898e4c
session.md: b2d4e093a019df5c43f705808b964b9cd7606834
session.zh.md: ce37052244c69ea6c10a479ca9e92ed012e93a72

View File

@@ -311,6 +311,8 @@ The same provenance distinction applies here: only `assistant/message` may carry
`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation.
`SurfaceManager(log, baseSeq?)` can instead fold a contiguous loaded window whose first event has the absolute sequence `baseSeq`. Every event remains contiguous in that absolute sequence space, and a replacement that crosses the window head fails because its declared range is absent.
```ts type-equiv
/** Readonly live projection of the message-producing session events. */
interface SessionSurface {

View File

@@ -313,6 +313,8 @@ interface SurfaceIntent {
`Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。
`SurfaceManager(log, baseSeq?)` 也可以折叠一个连续的已加载窗口,其第一个事件的绝对序号为 `baseSeq`。每个事件在该绝对序号空间中仍保持连续;如果替换跨过窗口头部,由于其声明的范围并不存在,该替换会失败。
```ts type-equiv
/** Readonly live projection of the message-producing session events. */
interface SessionSurface {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 3e9bcdd587764ee21326c27e0cd642914cf84f8f
README.zh.md: 8e13bc3d09ae220f7a64640b21892c03a37bae29
README.md: f5dd94bc1c937b69d74f450f11bf875b50af4c62
README.zh.md: 270d2d41e266fe198f91a939eba946e2f86844d9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Workspace 与 Session 列表

View File

@@ -9,6 +9,8 @@ export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
baseSeq: number
inspection: SessionHistoryInspection
}

View File

@@ -97,11 +97,6 @@ export class SessionHistorySource implements SessionHistoryFace {
return this.baseSeq !== previousBaseSeq
}
/** Rebuild the tail for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
await this.open()
}
/**
* Route a relevant mux frame without involving the Chat session.
* @param frame - Session-addressed frame.
@@ -145,7 +140,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.loadForConsumers()
void this.open()
}
/** Stop future refresh work after the host removes the session. */
@@ -408,6 +403,7 @@ export class SessionHistorySource implements SessionHistoryFace {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}

View File

@@ -34,6 +34,7 @@ describe('SessionHistorySource', () => {
expect(api.callsOf('session.history')).toHaveLength(1)
expect(source.getSnapshot().hasMore).toBe(true)
expect(source.getSnapshot().baseSeq).toBe(12)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([13, 15])
@@ -43,6 +44,7 @@ describe('SessionHistorySource', () => {
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().baseSeq).toBe(0)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})

View File

@@ -19,7 +19,7 @@ import type {
import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
@@ -27,6 +27,7 @@ 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
@@ -157,9 +158,8 @@ interface ToolCallTextParts {
interface SelectedRequest {
turn: number | null
section: number
number: number
group: string
seq?: number
}
interface DetailsResizeDrag {
@@ -333,7 +333,7 @@ export interface TrajectoryTableProps {
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** First loaded history node, used to preserve scroll position after prepending a page. */
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
hasOlderRecords?: boolean
@@ -345,10 +345,10 @@ export interface TrajectoryTableProps {
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
onToggleTurn: (turn: number) => void
/** Assistant record indexes whose tool calls are folded. */
collapsedAssistants: ReadonlySet<number>
/** Stable Assistant record ids whose tool calls are folded. */
collapsedAssistants: ReadonlySet<string>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
onToggleAssistant: (id: string) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
@@ -427,6 +427,7 @@ 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
}
@@ -581,14 +582,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
collapsedAssistants: ReadonlySet<string>,
): TableRecord[] {
const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
if (record === undefined) continue
out.push(record)
if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue
if (
record.cell.kind !== 'message'
|| !collapsedAssistants.has(trajectoryRecordId(record.cell))
) continue
const calls: TableRecord[] = []
for (let j = i + 1; j < records.length; j++) {
const candidate = records[j]
@@ -1581,7 +1585,7 @@ export function TrajectoryTable({
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(false)
@@ -1596,14 +1600,18 @@ export function TrajectoryTable({
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const [tableScrollReady, setTableScrollReady] = useState(false)
const pendingScrollIndex = useRef<number | null>(null)
const pendingScrollRecordId = useRef<string | null>(null)
const loadingOlder = useRef(false)
const [olderLoading, setOlderLoading] = useState(false)
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const selected = selectedRecordId === null
? undefined
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId)
const selectedIndex = selected?.cell.index ?? null
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
@@ -1631,7 +1639,7 @@ export function TrajectoryTable({
const record = records[index]
return record === undefined
? index
: `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`
: `${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`
},
getScrollElement: () => tablePaneRef.current,
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
@@ -1649,7 +1657,6 @@ export function TrajectoryTable({
})
: records.map((record, position) => ({ record, position }))
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
@@ -1662,16 +1669,20 @@ export function TrajectoryTable({
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
&& record.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestNumber = selectedRequest === null
? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
: sessionRequestNumbers?.find(request => selectedRequest.seq === undefined
? request.turn === selectedRequest.turn && request.group === selectedRequest.group
: request.seq === selectedRequest.seq)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestInfo?.status
@@ -1715,7 +1726,9 @@ export function TrajectoryTable({
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
const activeSection = selectedRequest === null
? selected?.section
: selectedRequestRecords[0]?.section
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1727,13 +1740,17 @@ export function TrajectoryTable({
const selectedAssistantRequest = selected?.cell.kind === 'message'
? requestNumbers.get(requestKey(selected.turn, selected.group))
: undefined
const selectedAssistantRequestInfo = selectedAssistantRequest === undefined
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
section: selected.section,
number: selectedAssistantRequest,
group: selected.group,
...(selectedAssistantRequestInfo?.seq === undefined
? {}
: { seq: selectedAssistantRequestInfo.seq }),
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
@@ -1752,7 +1769,7 @@ export function TrajectoryTable({
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(null)
}
@@ -1765,7 +1782,7 @@ export function TrajectoryTable({
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
setSelectedIndex(index)
setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell))
if (record === undefined) return
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
@@ -1779,19 +1796,25 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
pendingScrollIndex.current = recordSelection.index
}, [recordSelection, selectRecord])
const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordSelection, selectRecord])
useEffect(() => {
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
appliedRecordFocus.current = recordFocus
pendingScrollIndex.current = recordFocus.index
}, [recordFocus])
const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordFocus])
const selectRequest = (
request: SelectedRequest,
tab: 'overview' | 'timing' = 'overview',
) => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(request)
activateTab(tab)
}
@@ -1804,12 +1827,13 @@ export function TrajectoryTable({
const candidate = allRecords[i]
if (candidate === undefined || candidate.turn !== target.turn) break
if (candidate.cell.kind !== 'message') continue
if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index)
const assistantId = trajectoryRecordId(candidate.cell)
if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId)
break
}
}
setSelectedRequest(null)
setSelectedIndex(target.cell.index)
setSelectedRecordId(trajectoryRecordId(target.cell))
activateTab('overview')
}
@@ -1829,22 +1853,24 @@ export function TrajectoryTable({
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
pendingScrollRecordId.current = trajectoryRecordId(target.cell)
onInspectApplied?.()
}, [inspectCallId, turns, onInspectApplied])
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const id = pendingScrollRecordId.current
if (id === null) return
const position = records.findIndex(record =>
record.cell.index === index && record.collapsedSummary === undefined)
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
if (position === -1) return
pendingScrollIndex.current = null
pendingScrollRecordId.current = null
if (virtualizationEnabled) {
rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' })
return
}
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
const recordIndex = records[position]?.cell.index
const row = recordIndex === undefined
? null
: rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`)
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -2027,14 +2053,13 @@ export function TrajectoryTable({
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.section === record.section
&& selectedRequest.number === request
&& selectedRequest.group === record.group
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
key={`${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`}
tabIndex={isRequestOnly ? -1 : 0}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
@@ -2064,7 +2089,7 @@ export function TrajectoryTable({
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
@@ -2079,7 +2104,7 @@ export function TrajectoryTable({
&& assistantToolCalls(allRecords, record.cell.index).length > 0
) {
event.preventDefault()
onToggleAssistant(record.cell.index)
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
@@ -2098,7 +2123,7 @@ export function TrajectoryTable({
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
@@ -2121,9 +2146,8 @@ export function TrajectoryTable({
event.stopPropagation()
selectRequest({
turn: record.turn,
section: record.section,
number: request,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
@@ -2355,7 +2379,7 @@ export function TrajectoryTable({
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
Request #{selectedRequestNumber ?? '—'}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
@@ -2655,7 +2679,7 @@ export function TrajectoryTable({
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<span>Request #{selectedAssistantRequest ?? '—'}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}

View File

@@ -155,7 +155,7 @@
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
width: max(
0px,
2px,
calc(
var(--trajectory-span-width)
- var(--trajectory-span-gap)
@@ -163,6 +163,7 @@
)
);
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-secondary);
opacity: 0.78;

View File

@@ -26,9 +26,11 @@ import {
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
@@ -172,15 +174,23 @@ function searchMatches(
return matches
}
function mergeSearchMatches(
finalized: ReadonlySet<number> | null,
partial: ReadonlySet<number> | null,
): ReadonlySet<number> | null {
if (finalized === null || partial === null) return null
return new Set([...finalized, ...partial])
}
export function TrajectoryView({
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const actualDuration = useDuration(value => value)
@@ -197,6 +207,7 @@ export function TrajectoryView({
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
@@ -382,11 +393,23 @@ export function TrajectoryView({
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
const partialSearchMatches = useMemo(
() => searchMatches(partialSearchTurns, searchQuery),
[partialSearchTurns, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
@@ -420,14 +443,16 @@ export function TrajectoryView({
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
const ids: string[] = []
for (const turn of turns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
if (cell?.kind !== 'message') continue
const next = cells[i + 1]
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
if (next?.kind === 'tool' || next?.kind === 'subtool') {
ids.push(trajectoryRecordId(cell))
}
}
}
return ids
@@ -456,11 +481,11 @@ export function TrajectoryView({
})
}
const toggleAssistant = (index: number) => {
const toggleAssistant = (id: string) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
if (collapsed.has(id)) collapsed.delete(id)
else collapsed.add(id)
return collapsed
})
}
@@ -514,7 +539,7 @@ export function TrajectoryView({
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
branchKey: currentBranch.key,
range,
})
}}
@@ -529,7 +554,7 @@ export function TrajectoryView({
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={turns}
timelineFocusIndexes={timelineFocusIndexes}
@@ -539,7 +564,7 @@ export function TrajectoryView({
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={nodes.at(0)?.seq}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}

View File

@@ -7,6 +7,8 @@ import type {
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
@@ -18,6 +20,7 @@ export interface TrajectoryContextBranch {
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
@@ -63,6 +66,9 @@ export function deriveTrajectoryContextBranches(
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
@@ -84,6 +90,7 @@ export function deriveTrajectoryContextBranches(
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),

View File

@@ -517,10 +517,16 @@ export function appendTrajectoryPartialLayout(
const group = groups[groupIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (group === undefined) continue
const streamedCallIds = new Set(
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
)
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell => cell.requestOnly !== true),
...group.cells.filter(cell =>
cell.requestOnly !== true
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
),
...streamedGroup.cells,
],
}
@@ -641,6 +647,7 @@ function expandAssistant(
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''

View File

@@ -37,6 +37,8 @@ export interface TrajectorySourceBlock {
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based record index shown as `#N`. */
index: number
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
@@ -91,6 +93,18 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
selected?: boolean
}
/**
* Resolve the identity that survives prepending older projected records.
* @param cell - Projected trajectory record.
* @returns Stable identity from the owning event or tool call, with a fixture fallback.
*/
export function trajectoryRecordId(cell: TrajectoryCellProps): string {
if (cell.recordId !== undefined) return cell.recordId
if (cell.callId !== undefined) return `${cell.kind}\u0000call\u0000${cell.callId}`
if (cell.sourceSeq !== undefined) return `${cell.kind}\u0000seq\u0000${cell.sourceSeq}`
return `${cell.kind}\u0000index\u0000${cell.index}`
}
/**
* Format own-duration for the trailing time column.
* @param seconds - Duration seconds, or `null` when absent.

View File

@@ -71,6 +71,7 @@ describe('trajectory context branches', () => {
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.key).toBe('rewind:110')
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
@@ -85,4 +86,15 @@ describe('trajectory context branches', () => {
request('assistant', 111),
)).toBe(true)
})
it('keeps branch identity when prepended generations shift local ids', () => {
const branch = (id: number) => deriveTrajectoryContextBranches([{
id,
origin: 'rewind',
originSeq: 110,
nodes: [current],
}])[0]
expect(branch(1)?.key).toBe(branch(9)?.key)
})
})

View File

@@ -140,6 +140,34 @@ describe('deriveTrajectoryLayout', () => {
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
})
it('replaces a running-call placeholder with the matching streamed tool call', () => {
const partial = {
turn: 1,
step: 1,
blocks: [{
kind: 'tool-call' as const,
callId: 'c1',
name: 'bash',
argsRaw: '{"command":"pwd"}',
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
}],
})
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
const cells = streamed[0]?.groups[0]?.cells ?? []
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
})
it('omits duration when node times are missing instead of rendering NaN', () => {
const nodes = [
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },

View File

@@ -60,7 +60,7 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
collapsedAssistants: new Set<string>(),
onToggleAssistant: () => {},
}
@@ -189,6 +189,93 @@ describe('TrajectoryTable', () => {
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps the selected record when older rows shift projection indexes', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'selected tail response',
outputDetail: 'selected tail response detail',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Message',
cells: [{
index: 1,
kind: 'user',
sourceSeq: 1,
text: 'older prompt',
timeSeconds: 0,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('row', { name: /selected tail response/ })
.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('selected tail response detail')).toBeTruthy()
})
it('keeps a selected request when prepending changes its display number', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'tail response',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
sourceSeq: 1,
text: 'older response',
timeSeconds: 1,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('button', { name: 'Request #2' })
.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('Request #2')).toBeTruthy()
})
it('follows appended records only while the ledger is already at the bottom', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
@@ -339,6 +426,55 @@ describe('TrajectoryTable', () => {
expect(screen.queryByText('Context 1')).toBeNull()
})
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', {
configurable: true,
value: vi.fn(),
})
const turns: readonly TrajectoryTurnModel[] = Array.from(
{ length: 101 },
(_, index) => ({
turn: index + 1,
groups: [{
title: 'Step 1',
cells: [
{
index: index * 2 + 1,
kind: 'message' as const,
sourceSeq: index * 2 + 1,
text: `Message ${index + 1}`,
timeSeconds: 1,
},
{
index: index * 2 + 2,
kind: 'tool' as const,
callId: `call-${index + 1}`,
text: `Tool ${index + 1}`,
timeSeconds: 1,
},
],
}],
}),
)
const collapsedTurns = new Set(turns.flatMap(turn =>
turn.turn === null ? [] : [turn.turn]))
const view = render(
<TrajectoryTable
turns={turns}
{...FOLD_PROPS}
collapsedTurns={collapsedTurns}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 5_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
})
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()

View File

@@ -73,6 +73,7 @@ function historySnapshot(
state: 'ready',
error: null,
hasMore: false,
baseSeq: nodes[0]?.seq ?? 0,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
@@ -1153,6 +1154,74 @@ describe('TrajectoryView branches', () => {
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
})
it('does not remount the ledger when prepending shifts a rewind generation id', () => {
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'stable rewind response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const snapshot = (id: number) => historySnapshot([current], {
contexts: [{
id,
origin: 'rewind' as const,
originSeq: 4,
nodes: [current],
}],
})
const store = createSnapshotStore(snapshot(1))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
const row = screen.getByRole('row', { name: /stable rewind response/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
act(() => { store.set(snapshot(2)) })
expect(screen.getByRole('row', { name: /stable rewind response/ })
.getAttribute('aria-selected')).toBe('true')
})
it('keeps ledger and timeline selection on the same event after prepend', () => {
const older = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'older prompt' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'selected current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot([current]))
const view = render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
act(() => { store.set(historySnapshot([older, current])) })
const row = screen.getByRole('row', { name: /selected current response/ })
expect(row.getAttribute('aria-selected')).toBe('true')
const currentIndex = row.getAttribute('data-record-index')
expect(view.container.querySelector(
`[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
)).toBeTruthy()
})
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,

View File

@@ -273,6 +273,20 @@ describe('SurfaceManager', () => {
expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1])
})
it('rejects a replacement that crosses a loaded window head', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
{
...provenanceEvent(baseSeq + 1, [baseSeq - 1, baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq - 1, end: baseSeq },
},
] as SessionEvent[]
expect(() => new SurfaceManager(events, baseSeq).nodes)
.toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`)
})
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
s.append('user/message', createUserMessage({