Merge remote-tracking branch 'origin/master' into feat/pwsh-ui-parity

# Conflicts:
#	apps/web/tsconfig.json
This commit is contained in:
Huanqi Cao
2026-08-05 21:51:31 +08:00
78 changed files with 3904 additions and 406 deletions
@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 79f944b74976f82d5233a8d55916eff49aa7bf86
2026-07-25-web-client-session-scope-and-provide-channel.md: 353cf35c9d6f5fa93a97fb0be60303ad6cef4d14
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b6d1a20073e1a43414d43b830ccc8ac2b1573bb2
@@ -64,17 +64,17 @@ A session "materialized but with no first prompt" is governed by the summary-der
- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility.
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
### connectWorkspace: the sole entry point of New Session
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference):
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing.
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
- An unknown workspaceId fails loud (never silently creating somewhere else).
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
@@ -64,17 +64,17 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
### connectWorkspaceNew Session 的唯一入口
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):
- 复用臂:list mirror 中找 `blank && cwd == workspace.path`host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。
- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud(不静默创建到别处)。
- 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034
2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876
@@ -0,0 +1,50 @@
# Agent Note: The conversation column reserves one scrollbar gutter for every view
Status: implemented
English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md)
## Problem
The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown.
In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces.
So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn.
## Decision
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`.
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it.
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
## Alternatives considered
**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is.
**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent.
**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one.
**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it.
## Consequences
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
## Testing
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
@@ -0,0 +1,50 @@
# Agent Note: 会话列为每个视图预留同一条滚动条槽
Status: implemented
[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文
## 问题
composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。
在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。
于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。
## 决策
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
## 曾考虑的替代方案
**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。
**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。
**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。
**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。
## 后果
- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
## 测试
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md
2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0
@@ -0,0 +1,29 @@
# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions
Status: implemented
English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md)
## Problem
Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there.
Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run.
## Decision
The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists".
## Alternatives considered
**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself.
**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface.
## Consequences
Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. Membership is a new condition on the reuse scan, and it has one observable stale-mirror edge: in the window where the session mirror is fresh but the Workspace account frame lags, the Workspace's own member blank can fail the membership check and a duplicate blank is minted where the old code reused — a second `New Session` row under that Workspace rather than the old failure shape (a session that no grouping surface shows). Both windows are transient and the per-Workspace coalescing still prevents duplicate creates racing one another. No host, wire, or durable-format change.
## Testing
`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green.
@@ -0,0 +1,29 @@
# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话
状态:已实现
[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文
## 问题
在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。
根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。
## 决定
复用扫描现在要求工作区成员关系:`blank``summary.cwd === workspace.path``workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。
## 曾考虑的替代方案
**收养游离会话而不是新建。**`session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。
**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。
## 后果
游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。
## 测试
`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
@@ -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-23-web-assistant-markdown.md
2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76
2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859
2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58
2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187
@@ -14,7 +14,7 @@ The Web conversation preserves assistant Markdown source through session events,
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle.
@@ -38,4 +38,4 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Consequences
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred.
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.
@@ -14,7 +14,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`MarkdownText` 使用 `react-markdown``remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math``rehype-katex` 渲染 KaTeX`remarkMathCompatibility``\(...\)``\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记不在范围内;GFM 任务列表继续使用原生复选框。
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
@@ -38,4 +38,4 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 后果
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchorthinking-small 表层仍暂缓。
@@ -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: cdeaa30ea64f47b0e0110baf566f747a4591a384
2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c
2026-07-27-trajectory-inspection-ledger.md: e3fc22234c1df449c99eac90af27de7da0b6f202
2026-07-27-trajectory-inspection-ledger.zh.md: abf8fd3bcaeabba6061bd415acbe8ddefef7ac79
@@ -16,13 +16,16 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer.
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative.
- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory opens the source's tail while mounted and requests one older page when the user reaches the loaded range's top, then lazily derives event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer.
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive.
- 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.
- 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. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. 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.
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. 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.
- 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. 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.
@@ -32,6 +35,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower.
**Mount every projected record in the table.** Rejected: record projection remains useful for search, timing, and navigation, but keeping every row and its descendants in the DOM makes browser rendering scale with the complete session instead of the visible viewport.
**Exhaust every history page when Trajectory mounts.** Rejected: complete session metrics would be immediately available, but transporting and repeatedly projecting old chunk-heavy pages delays inspection of the current tail. On-demand backward paging makes that cost follow the user's navigation.
**Rebuild the loaded ledger for every streamed token chunk.** Rejected: virtual rows bound DOM work but do not make repeated history folding cheap. Keeping finalized projections stable makes ordinary deltas proportional to the current partial, while structural events remain the explicit full-rebuild boundary.
**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows.
**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
@@ -44,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. Focused component tests pin tail following, 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.
@@ -16,13 +16,16 @@ Status: implemented
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口
- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。
- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。
- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
@@ -32,6 +35,12 @@ Status: implemented
**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
**在表格中挂载每条投影记录。** 不予采纳:记录投影仍可用于搜索、计时和导航,但把每一行及其后代都保留在 DOM 中,会使浏览器渲染开销随完整会话增长,而非随可见视口增长。
**Trajectory 挂载时补齐所有历史页面。** 不予采纳:完整会话指标可以立即获得,但传输并反复投影含大量分片的旧页面会延迟对当前尾部的检查。按需向前分页会让这项成本随用户导航产生。
**每收到一个流式 token 分片就重建已加载记录表。** 不予采纳:虚拟行限制了 DOM 工作量,却不会让反复折叠历史变得低廉。保持已完成投影稳定,可以让普通增量的成本只随当前未完成部分增长,而结构事件仍是显式的完整重建边界。
**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
@@ -44,4 +53,4 @@ Status: implemented
## 后果
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表契约锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
+6
View File
@@ -46,6 +46,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
@@ -63,6 +64,11 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
+2 -2
View File
@@ -1,7 +1,7 @@
// 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 let tests identify semantic
// rows without depending on CSS-module names or the eventual virtualizer DOM.
// persisted conversations, while unique markers identify semantic rows
// without depending on CSS-module names or virtualizer DOM positions.
import {
CallId,
createAssistantMessage,
+420
View File
@@ -0,0 +1,420 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
// reported symptom as a number: the card moves 4px, half the 8px band, on each
// edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
import { fileURLToPath } from 'node:url'
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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TAB_GEOMETRY',
title: 'COMPOSER_TAB_GEOMETRY long session',
turns: 24,
})
const SEED_ID = 'composer-tab-geometry-web-e2e'
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
const NARROW_VIEWPORT = { width: 800, height: 1000 }
/**
* Resize to one measurement viewport after the responsive sidebar and center
* column finish their track transition.
* @param page - the page under test.
* @param viewport - the viewport dimensions to apply.
* @param sidebarCollapsed - the sidebar state expected at this width.
*/
async function setMeasuredViewport(
page: Page,
viewport: { width: number; height: number },
sidebarCollapsed: boolean,
): Promise<void> {
await page.setViewportSize(viewport)
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: sidebarCollapsed ? 'attached' : 'detached',
timeout: 10_000,
})
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
const deadline = performance.now() + 5_000
let previous = host.getBoundingClientRect().width
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = host.getBoundingClientRect().width
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return
previous = current
}
throw new Error('conversation width did not settle after the viewport changed')
})
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
/** One tab's metrics beside the other's, plus the distances between them. */
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
/**
* Measure the column scroller and the input card in the tab currently shown.
* @param page - the page under test.
* @returns the scroller's resolved overflow style and the card's rectangle.
*/
function measureTab(page: Page): Promise<TabMetrics> {
return page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation column scroller not in the DOM')
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
if (card === null) throw new Error('no input card inside the composer seat')
const style = getComputedStyle(host)
const hostRect = host.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
gutter: style.scrollbarGutter,
overflowX: style.overflowX,
overflowY: style.overflowY,
band: hostRect.width - host.clientWidth,
scrolls: host.scrollHeight > host.clientHeight,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardWidth: cardRect.width,
}
})
}
/**
* Show one tab and wait for the view that owns it to be laid out.
* @param page - the page under test.
* @param tab - the tab to show.
*/
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
await page.getByRole('tab', { name: tab, exact: true }).click()
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
// Both measurements are taken after a paint, so a rectangle read mid-transition
// cannot be reported as a shift the cascade did not cause.
await page.evaluate(() => new Promise<void>((settle) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
}))
}
/**
* Measure both tabs and the distances between them, leaving Chat shown.
* @param page - the page under test.
* @returns each tab's metrics and the card's displacement between them.
*/
async function compareTabs(page: Page): Promise<TabComparison> {
await showTab(page, 'Chat')
const chat = await measureTab(page)
await showTab(page, 'Trajectory')
const trajectory = await measureTab(page)
await showTab(page, 'Chat')
return {
chat,
trajectory,
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
}
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
}
/**
* Open the seeded session from the sidebar search.
*
* Cold summaries carry the temp workspace's basename, so the persisted first
* message is the stable identity to search for, and the query itself drives the
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
* is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
for (;;) {
if (await results.count() === 1) break
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
/**
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
const section = (name: string, comparison: TabComparison): string[] => [
`## ${name}`,
'',
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
`- Chat reserved band: ${String(comparison.chat.band)}px`,
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
'',
]
return [
'# Input card position across the Chat and Trajectory tabs',
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control),
].join('\n').trimEnd()
}
describe('web e2e: input card position across view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
// would make the tabs agree without the reservation doing anything.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
expect(comparison.trajectory.band).toBe(comparison.chat.band)
// Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
expect(comparison.trajectory.scrolls).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place when the tab changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const capped = await measureTab(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('moves the card again once the reservation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const wide = await compareTabs(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The seeded session is generated in-process, so the geometry golden is the
// whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})
+127
View File
@@ -0,0 +1,127 @@
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'math-rendering-web-e2e'
const DONE = 'MATH_RENDERING_DONE'
/** Build a settled assistant reply that exercises every supported math delimiter. */
function mathFixture(): string {
const session = Session.create(SessionId('math-rendering-source'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Render this mathematical proof.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Math rendering',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Math rendering',
'',
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe('web e2e: settled Markdown math rendering', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mathFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})
@@ -0,0 +1,37 @@
# Input card position across the Chat and Trajectory tabs
## Wide viewport (1680px, card at its cap)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Narrow viewport (800px, card shrinking with the column)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
- Trajectory scroller scrolls: false
- Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px
- input card right edge moves between tabs: 4px
- input card width changes between tabs: 0px
@@ -0,0 +1,47 @@
- banner:
- navigation "Session hierarchy":
- button "Math rendering" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render this mathematical proof. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Math rendering" [level=2]
- paragraph:
- text: Inline dollar
- math: θ
- text: and backslash
- math: 1 5
- text: .
- math: π 4 < θ < π 2
- math: θ ∈ ( π 4 , π 2 ) . (1)
- table:
- rowgroup:
- row "Symbol Value":
- columnheader "Symbol"
- columnheader "Value"
- rowgroup:
- row:
- cell:
- math: θ
- cell:
- math: 1 5
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
@@ -5,7 +5,7 @@
- img
- searchbox "Search trajectory"
- region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":
@@ -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)
})
+12 -6
View File
@@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(
() => reregistered?.sessionIds ?? [],
{ timeout: 10_000 },
).not.toEqual([])
expect(reregistered?.sessionIds).not.toContain(SEED_ID)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+4
View File
@@ -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",
@@ -48,6 +50,7 @@
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",
@@ -61,6 +64,7 @@
"tests/chat-scroll-contract.e2e.ts",
"tests/chat-long-interactions.e2e.ts",
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts"
],
+2 -2
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: c5491b8d6b44c0a86ce5804320533925a0e6e287
session.zh.md: 3a713c77e972096cf95223701e8aff4cb9d0ff87
session.md: e6cc05179acd03e2e4564e4690ed2a119ae6d35e
session.zh.md: 0ed5fe99546681ff003e761eabb835e98edf19d5
+2
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 {
+2
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 {
+2 -2
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: db046202b63562a9282c7f4dd1f220d356a21dad
README.zh.md: 07b80eff9f3dc992cc57ba2f963dd76759a65787
README.md: e55587202dfdd8b2d3e7e707507ebe041769b7c5
README.zh.md: 0d5092074592d49807d06e1bb70fc64fc2d6dcf4
+2 -2
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; 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
@@ -20,7 +20,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
## Pending queue projection
+2 -2
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 列表
@@ -20,7 +20,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
## 待处理队列投影
@@ -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
}
@@ -17,11 +19,17 @@ export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the tail and exhaust every available older page.
* @param signal - Consumer lifetime; abort is observed between page requests.
* @returns When the available ledger is complete or stops advancing.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadAll(signal?: AbortSignal): Promise<void>
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** Runtime service resolving independent history sources. */
@@ -49,18 +49,10 @@ function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
// Trajectory owns surface-window reconstruction so its immutable ledger does
// not depend on Chat's live fold adapter or Session's mutable state.
/* jscpd:ignore-start */
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/* jscpd:ignore-end */
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
@@ -84,27 +76,63 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']):
}
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
function foldContexts(
events: readonly SessionEvent[],
): readonly FoldedContext[] {
const replay: SessionEvent[] = []
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
@@ -332,10 +360,7 @@ export function projectConversationHistory(
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const baseSeq = events[0]?.seq ?? 0
const padded = [
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
...events,
]
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>()
@@ -405,7 +430,7 @@ export function projectConversationHistory(
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = padded[seq]
const event = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
@@ -431,7 +456,7 @@ export function projectConversationHistory(
}]
} else {
try {
contexts = foldContexts(padded).map((context): ConversationContext => {
contexts = foldContexts(events).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
@@ -444,7 +469,7 @@ export function projectConversationHistory(
nodes,
}
}
const originEvent = padded[context.originSeq]
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 { isVisibleAssistantChunk, 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'
@@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace {
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
@@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace {
}
/**
* Load the tail and exhaust all available older pages.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
* @returns When the tail is ready or loading fails.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) return
this.trackConsumer(signal)
await this.open()
while (
!isAborted(signal)
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
}
}
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
await this.open()
while (
this.hasConsumer()
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
}
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/**
@@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace {
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.loadForConsumers()
void this.open()
}
/** Stop future refresh work after the host removes the session. */
@@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace {
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
@@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace {
}
}
private loadOlder(): Promise<void> {
private loadOlderPage(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
@@ -260,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) {
@@ -291,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)
@@ -324,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. */
@@ -336,11 +336,10 @@ 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.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
@@ -356,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
@@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace {
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
@@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}
/** 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
@@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot {
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* top-level collection. A leading resume/change header exposes its prompt but
* cannot project a change until the preceding header enters the window.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
@@ -218,6 +219,7 @@ function promptChange(
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
if (previous === undefined && event.data.reason !== 'initial') return
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
@@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces {
// would miss the reuse scan and mint another hidden blank session.
const inflight = this.connecting.get(workspaceId)
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
// An archived blank is never reused: reuse would open a session no
// grouping surface can show, so New Session mints a fresh one instead.
// Reuse requires workspace membership (id in sessionIds AND same
// canonical cwd the host's own membership rule), never cwd alone:
// a cwd match can belong to no account (sessions the CLI/TUI birthed at
// the host cwd, or a deleted/recreated registration) and reusing it
// would open a session no grouping surface shows under this workspace.
// An archived blank is never reused either: reuse would open a session
// no grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& workspace.sessionIds.includes(summary.id)
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
@@ -2,12 +2,45 @@ 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 =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [
ev.user(baseSeq, 'loaded tail'),
at(baseSeq + 1, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
data: {
turn: 80,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'tail summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
expect(projection.contexts.map(context => ({
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ originSeq: undefined, nodes: [baseSeq] },
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
])
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
@@ -91,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))
})
})
@@ -85,6 +85,56 @@ describe('inspectRequests', () => {
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('does not promote a truncated resume or change header to the initial prompt', () => {
for (const reason of ['resume', 'change'] as const) {
const snapshot = inspectRequests(entriesOf([
at(10, 'step/start', { turn: 3, step: 1 }),
at(11, 'request/header', {
reason,
header: {
config: { provider: 'fake', model: 'model' },
system: 'tail-window prompt',
},
}),
]))
expect(snapshot.requests[0]).toMatchObject({
purpose: 'assistant',
prompt: { system: 'tail-window prompt' },
})
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
}
})
it('classifies a prompt change once the preceding header is loaded', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'before',
},
}),
at(2, 'step/start', { turn: 1, step: 2 }),
at(3, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'model' },
system: 'after',
},
}),
]))
expect(snapshot.requests[1]).toMatchObject({
promptChange: {
seq: 3,
kind: 'system',
previous: { system: 'before' },
},
})
})
it('preserves a standalone compaction owner without widening assistant turns', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
it('loads the tail first and prepends older pages on demand', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
@@ -30,10 +30,21 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
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])
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(false)
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])
})
@@ -42,7 +53,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const before = source.getSnapshot()
source.handleMuxFrame({
@@ -60,7 +71,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
@@ -132,13 +143,14 @@ describe('SessionHistorySource', () => {
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
it('finishes an already started older page after consumer cancellation', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
@@ -151,7 +163,8 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await source.loadTail(controller.signal)
const complete = source.loadOlder(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
@@ -159,7 +172,7 @@ describe('SessionHistorySource', () => {
hasMore: true,
}))
await complete
expect(await complete).toBe(true)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
@@ -149,26 +149,37 @@ describe('WorkspacesService', () => {
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha'), workspace('beta')] as never[],
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
}))
api.onList = () => Promise.resolve(ok({
items: [
// Blank session already parked in alpha (cwd == workspace path canon).
// Stray blank at alpha's path but NOT accounted under alpha (a CLI
// session birthed at the host cwd), sorted before the member blank:
// the scan must skip it and keep looking for a member hit.
{ sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
// Blank session parked in alpha (cwd == workspace path canon AND
// accounted under alpha): the reuse hit.
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
// Non-blank sibling in beta must never be reused.
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
// session birthed at the host cwd): cwd alone must not hijack it —
// reuse would open a session gamma cannot show, so New Session mints
// a fresh accounted one instead.
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
// Hit: same workspace → the parked blank session comes back, no create RPC.
// Hit: same workspace → the parked member blank comes back (the earlier
// cwd-matching non-member stray is skipped), no create RPC.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
// Resolution guarantee: the id is binding-resolvable synchronously.
@@ -181,6 +192,12 @@ describe('WorkspacesService', () => {
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
// Miss: the stray blank matches gamma's path but is not a gamma member →
// never reused, a fresh accounted session is created instead.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
@@ -196,7 +213,7 @@ describe('WorkspacesService', () => {
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))
@@ -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-conversation/README.md
README.md: d7433c151b7ab8da66d319ce71dd24f533739176
README.zh.md: 338cb3a8673a24a387a96c4c5e65a528e01c4c68
README.md: 3b4d2f2c1d7934d619768f2b3b355c8c585290cc
README.zh.md: e3664a0d621214cced2d8a0d7d5d5f7800f15d90
+1 -1
View File
@@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
+1 -1
View File
@@ -6,7 +6,7 @@
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环是一个 slot:会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`),视图标签页则从注册选项(`id``order``label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
@@ -191,6 +191,11 @@
flex-direction: column;
min-height: 0;
overflow-y: auto;
/* Reserved unconditionally: the composer seat rides this box's content box in
Chat and its padding box under a view's composer overlay, so an `auto`
gutter moves the input card sideways by the bar's width whenever the two
differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: stable;
}
.root[data-phase='active'] .viewArea {
@@ -221,7 +226,13 @@
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
overflow: hidden;
/* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: WebKit honours the reservation above
only in the `overflow-y: auto` form, and a single-axis scroller computes
the other axis to `auto`
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
overflow-x: hidden;
overflow-y: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
@@ -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-primitives/README.md
README.md: d4e6c2508f07f4832f2e12a836c2d447b656421e
README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15
README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf
README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43
+1 -1
View File
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
+2 -2
View File
@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 终端输出
@@ -44,6 +44,6 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层
- **StateDot 没有 `Active` 变体**:支持的状态为 donewarningongoingerror。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard``copyLabel`/`copiedLabel`)、`TerminalBlock``labels`)、`JsonTree``labels`)、`CodeBlock``copyLabel`/`copiedLabel`)、`MarkdownText``codeLabels`)、`JsonBlock``truncatedLabel`)、`ConnectionBanner``label`)和 `Modal``closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
@@ -27,6 +27,11 @@
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
@@ -5,11 +5,16 @@ import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const streamingRemarkPlugins = [remarkGfm]
const settledRemarkPlugins = [remarkGfm, remarkMath]
const settledRemarkPlugins = [
remarkGfm,
remarkMathCompatibility,
remarkMath,
]
const settledRehypePlugins = [rehypeKatex]
function sanitizeUrl(url: string): string {
@@ -0,0 +1,353 @@
/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */
import { factorySpace } from 'micromark-factory-space'
import type {} from 'micromark-extension-math'
import { markdownLineEnding } from 'micromark-util-character'
import { codes, constants, types } from 'micromark-util-symbol'
import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types'
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
/* v8 ignore next -- a previous code necessarily has a preceding event. */
if (tail === undefined) return false
return tail[1].type === types.characterEscape
}
const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the text construct is dispatched only for a backslash. */
if (code !== codes.backslash) return nok(code)
effects.enter('mathText')
effects.enter('mathTextSequence')
effects.consume(code)
return open
}
function open(code: number | null): State | undefined {
if (code !== codes.leftParenthesis) return nok(code)
effects.consume(code)
effects.exit('mathTextSequence')
return between
}
function between(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === codes.backslash) {
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code)
}
if (markdownLineEnding(code)) {
effects.enter(types.lineEnding)
effects.consume(code)
effects.exit(types.lineEnding)
return between
}
return dataStart(code)
}
function afterCloseAttempt(code: number | null): State | undefined {
return effects.check({ partial: true, tokenize: tokenizeOpen }, nok, dataStart)(code)
}
function dataStart(code: number | null): State | undefined {
effects.enter('mathTextData')
effects.consume(code)
return code === codes.backslash ? afterDataBackslash : data
}
function afterDataBackslash(code: number | null): State | undefined {
if (code === codes.backslash) {
effects.consume(code)
return data
}
return data(code)
}
function data(code: number | null): State | undefined {
if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) {
effects.exit('mathTextData')
return between(code)
}
effects.consume(code)
return data
}
function close(code: number | null): State | undefined {
effects.exit('mathText')
return ok(code)
}
function tokenizeClose(closeEffects: Parameters<Tokenizer>[0], closeOk: State, closeNok: State): State {
return slash
function slash(code: number | null): State | undefined {
/* v8 ignore next -- this partial construct is attempted only at a backslash. */
if (code !== codes.backslash) return closeNok(code)
closeEffects.enter('mathTextSequence')
closeEffects.consume(code)
return parenthesis
}
function parenthesis(code: number | null): State | undefined {
if (code !== codes.rightParenthesis) return closeNok(code)
closeEffects.consume(code)
closeEffects.exit('mathTextSequence')
return closeOk
}
}
function tokenizeOpen(openEffects: Parameters<Tokenizer>[0], openOk: State, openNok: State): State {
return slash
function slash(code: number | null): State | undefined {
/* v8 ignore next -- the opening check follows a failed close attempt at a backslash. */
if (code !== codes.backslash) return openNok(code)
openEffects.enter(types.chunkString)
openEffects.consume(code)
return parenthesis
}
function parenthesis(code: number | null): State | undefined {
if (code !== codes.leftParenthesis) return openNok(code)
openEffects.consume(code)
openEffects.exit(types.chunkString)
return openOk
}
}
}
function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct {
const tokenize: Tokenizer = function (effects, ok, nok) {
const self = this
let oddBackslashRun = false
const tail = self.events.at(-1)
const initialSize = tail?.[1].type === types.linePrefix
? tail[2].sliceSerialize(tail[1], true).length
: 0
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the flow construct is dispatched only for its marker. */
if (code !== marker) return nok(code)
effects.enter('mathFlow')
effects.enter('mathFlowFence')
effects.enter('mathFlowFenceSequence')
effects.consume(code)
return open
}
function open(code: number | null): State | undefined {
if (code !== openMarker) return nok(code)
effects.consume(code)
effects.exit('mathFlowFenceSequence')
effects.exit('mathFlowFence')
return marker === codes.dollarSign ? afterDollarOpen : content
}
function afterDollarOpen(code: number | null): State | undefined {
return code === codes.dollarSign ? nok(code) : content(code)
}
function content(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
afterClosingFenceAttempt,
)(code)
}
if (markdownLineEnding(code)) {
return multiline
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
: nok(code)
}
return valueStart(code)
}
function afterClosingFenceAttempt(code: number | null): State | undefined {
return marker === codes.backslash
? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
: markerValueStart(code)
}
function afterContinuation(code: number | null): State | undefined {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
initialSize
? factorySpace(effects, content, types.linePrefix, initialSize + 1)
: content,
)(code)
}
function valueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = code === codes.backslash
effects.consume(code)
return value
}
function markerValueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = false
effects.consume(code)
return valueAfterMarker
}
function valueAfterMarker(code: number | null): State | undefined {
if (code === marker) {
effects.consume(code)
return value
}
return value(code)
}
function value(code: number | null): State | undefined {
if (code === codes.eof || code === marker || markdownLineEnding(code)) {
effects.exit('mathFlowValue')
return content(code)
}
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
effects.consume(code)
return value
}
function closed(code: number | null): State | undefined {
effects.exit('mathFlow')
return ok(code)
}
function tokenizeClosingFence(
closeEffects: Parameters<Tokenizer>[0],
closeOk: State,
closeNok: State,
): State {
return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize)
function sequenceStart(code: number | null): State | undefined {
if (code !== marker) return closeNok(code)
closeEffects.enter('mathFlowFence')
closeEffects.enter('mathFlowFenceSequence')
closeEffects.consume(code)
return sequenceEnd
}
function sequenceEnd(code: number | null): State | undefined {
if (code !== closeMarker) return closeNok(code)
closeEffects.consume(code)
closeEffects.exit('mathFlowFenceSequence')
return factorySpace(closeEffects, after, types.whitespace)
}
function after(code: number | null): State | undefined {
if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code)
closeEffects.exit('mathFlowFence')
return closeOk(code)
}
}
function tokenizeOpeningFence(
openEffects: Parameters<Tokenizer>[0],
openOk: State,
openNok: State,
): State {
return sequenceStart
function sequenceStart(code: number | null): State | undefined {
/* v8 ignore next -- the opening check follows a failed close attempt at the marker. */
if (code !== marker) return openNok(code)
openEffects.enter(types.chunkString)
openEffects.consume(code)
return sequenceEnd
}
function sequenceEnd(code: number | null): State | undefined {
if (code !== openMarker) return openNok(code)
openEffects.consume(code)
openEffects.exit(types.chunkString)
return openOk
}
}
}
return {
concrete: true,
name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow',
tokenize,
}
}
const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) {
const self = this
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (code === codes.eof) return ok(code)
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (!markdownLineEnding(code)) return nok(code)
effects.enter(types.lineEnding)
effects.consume(code)
effects.exit(types.lineEnding)
return lineStart
}
function lineStart(code: number | null): State | undefined {
return self.parser.lazy[self.now().line] ? nok(code) : ok(code)
}
}
const nonLazyContinuation: Construct = {
partial: true,
tokenize: tokenizeNonLazyContinuation,
}
const backslashMathText: Construct = {
name: 'backslashMathText',
previous: previousBackslash,
tokenize: tokenizeBackslashMathText,
}
const backslashMathFlow = createMathFlow(
codes.backslash,
codes.leftSquareBracket,
codes.rightSquareBracket,
true,
)
const sameLineDollarMathFlow = createMathFlow(
codes.dollarSign,
codes.dollarSign,
codes.dollarSign,
false,
)
const backslashMath: Extension = {
flow: {
[codes.backslash]: backslashMathFlow,
[codes.dollarSign]: sameLineDollarMathFlow,
},
text: { [codes.backslash]: backslashMathText },
}
/**
* Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
* The same processor must register remark-math to compile the emitted math tokens.
* @returns Nothing.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
}
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
afterEach(cleanup)
@@ -171,6 +173,161 @@ describe('MarkdownText', () => {
expect(container.querySelector('a')).toBeNull()
})
it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
const source = [
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(6)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
.toContain('\\tag{1}')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('table .katex')).not.toBeNull()
})
it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
const cases = [
{
source: '\\(\\alpha \\, \\beta\\)',
math: 1,
display: 0,
},
{
source: String.raw`\\\(x\)`,
math: 1,
display: 0,
value: 'x',
},
{
source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
math: 1,
display: 0,
value: '\\frac{1}{5}\n+\\frac{1}{7}',
},
{
source: '\\[a\\\\\nb\\]',
math: 1,
display: 1,
value: 'a\\\\\nb',
},
{
source: '> \\[\n> \\frac{1}{5}\n> \\]',
math: 1,
display: 1,
},
{
source: '- \\[\n \\frac{1}{5}\n \\]',
math: 1,
display: 1,
},
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
if ('value' in item) {
expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
}
rendered.unmount()
}
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
expect(literal.container.querySelector('.katex-display')).toBeNull()
expect(literal.container.textContent).toContain('[x')
})
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
const cases = [
{ source: '$$\n\\theta\n$$', math: 1, display: 1 },
{ source: '$$$\\theta$$$', math: 1, display: 0 },
{ source: '$$a$b\nc', math: 0, display: 0 },
{ source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
{ source: '\\(\\theta', math: 0, display: 0 },
{ source: String.raw`\(a\\)`, math: 0, display: 0 },
{ source: '\\[\n\\[', math: 0, display: 0 },
{ source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('lets display math interrupt an open paragraph', () => {
for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
rendered.unmount()
}
})
it('leaves a dollar block with trailing text to upstream inline math', () => {
const { container } = render(<MarkdownText text="$$x$$ trailing" />)
expect(container.querySelectorAll('.katex')).toHaveLength(1)
expect(container.querySelector('.katex-display')).toBeNull()
expect(container.querySelector('annotation')?.textContent).toBe('x')
expect(container.textContent).toContain('trailing')
})
it('renders escaped dollars and even backslash pairs before closing fences', () => {
const source = [
String.raw`$$100\$$$`,
'',
String.raw`\(a\\\)`,
'',
String.raw`\[b\\\]`,
].join('\n')
const { container } = render(<MarkdownText text={source} />)
const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
expect(container.querySelector('.katex-error')).toBeNull()
})
it('bounds fallback work for repeated unclosed backslash delimiters', () => {
const startedAt = performance.now()
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
expect(performance.now() - startedAt).toBeLessThan(1_000)
expect(container.querySelector('.katex')).toBeNull()
})
it('leaves TeX-looking fenced code literal', () => {
const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelector('.katex')).toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
})
it('registers the compatibility extension on a bare remark processor', () => {
const data: { micromarkExtensions?: Extension[] } = {}
remarkMathCompatibility.call({ data: () => data })
expect(data.micromarkExtensions).toHaveLength(1)
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
@@ -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: 771e1a68e8027f02f20b17f78d0a8dd48d2d5bff
README.zh.md: ceb4d696e5fda117afff89cf9cd0a5426dfed2be
README.md: 2c737fe2d04df518e7d32d07aaede714a7a3566d
README.zh.md: 738d6cafa0c6d44f05fecec01d565da91ef433e8
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. 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
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'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 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
## 模型体验
+6 -2
View File
@@ -35,6 +35,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@tanstack/react-virtual": "^3.14.9",
"diff": "^9.0.0"
},
"peerDependencies": {
@@ -42,7 +43,8 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -51,8 +53,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -13,6 +13,7 @@
}
.tablePane {
position: relative;
flex: 1;
min-width: 0;
overflow-x: hidden;
@@ -21,6 +22,53 @@
container: trajectory-table / inline-size;
}
.historyLoading {
position: sticky;
z-index: 5;
top: 0;
height: 0;
overflow: visible;
pointer-events: none;
}
.historyLoadingBar {
display: flex;
width: 100%;
height: 30px;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
}
.historyLoadingSpinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border: 1.5px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-state-business-primary);
border-radius: 50%;
animation: history-loading-spin 700ms linear infinite;
}
.table:not([data-scroll-ready='true']) {
visibility: hidden;
}
@keyframes history-loading-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.historyLoadingSpinner {
animation: none;
}
}
.table {
--trajectory-turn-accent: color-mix(
in srgb,
@@ -79,7 +127,17 @@
white-space: nowrap;
}
.table tbody tr:not([data-collapsed-summary]) {
.table tbody .virtualSpacer {
pointer-events: none;
}
.table tbody .virtualSpacer td {
height: var(--trajectory-virtual-spacer-height);
padding: 0;
border: 0;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
cursor: default;
outline: none;
transition:
@@ -91,7 +149,7 @@
opacity: 0.24;
}
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -106,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;
}
@@ -620,6 +678,10 @@
white-space: nowrap;
}
.toolCallOnly {
color: var(--dsw-alias-label-tertiary);
}
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
@@ -936,6 +998,17 @@
overflow: auto;
}
.summaryScrollRegion {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
.summaryScrollRegion:hover,
.summaryScrollRegion:focus-within {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
@@ -1023,7 +1096,6 @@
margin: 0;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1033,7 +1105,6 @@
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
IconChevronRightOutline14,
IconSettingsOutline16,
@@ -18,11 +19,19 @@ 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 {
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_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
@@ -117,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'
@@ -150,9 +183,8 @@ interface ToolCallTextParts {
interface SelectedRequest {
turn: number | null
section: number
number: number
group: string
seq?: number
}
interface DetailsResizeDrag {
@@ -195,6 +227,16 @@ type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
type VirtualSpacerStyle = CSSProperties & {
'--trajectory-virtual-spacer-height': string
}
interface OlderLoadAnchor {
readonly historyStartSeq: number | undefined
readonly scrollHeight: number
readonly scrollTop: number
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -228,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
return `${day} ${time}`
}
/** Whether a click lands on an active text selection and should keep it. */
function clickSelectsText(target: Node): boolean {
const selection = window.getSelection()
return selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(target)
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -238,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
if (clickSelectsText(event.currentTarget)) return
setShowUnix(current => !current)
}}
>
@@ -302,6 +347,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. */
@@ -312,16 +359,26 @@ export interface TrajectoryTableProps {
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** One externally requested record focus without changing inspector selection. */
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
hasOlderRecords?: boolean
/** Load one older history page. */
onLoadOlder?: () => Promise<boolean>
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
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. */
@@ -492,7 +549,6 @@ function collapseTurnRecords(
records: readonly TableRecord[],
collapsedTurns: ReadonlySet<number>,
): TableRecord[] {
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
@@ -550,15 +606,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
collapsedAssistants: ReadonlySet<string>,
): TableRecord[] {
if (collapsedAssistants.size === 0) return [...records]
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]
@@ -1335,7 +1393,7 @@ function RequestTiming({
<dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div>
<div><dt>Duration</dt><dd></dd></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
</dl>
)
}
@@ -1519,7 +1577,12 @@ function OverviewSection({
<IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
</button>
</h3>
<div className={css.overviewPreview}>{children}</div>
<div
className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{children}
</div>
</section>
)
}
@@ -1533,11 +1596,17 @@ function OverviewSection({
export function TrajectoryTable({
requestNumbers: sessionRequestNumbers,
turns,
streamingCells = [],
timelineFocusIndexes = null,
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
recordFocus = null,
historyLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1546,7 +1615,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)
@@ -1554,20 +1623,116 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const [tableScrollReady, setTableScrollReady] = useState(false)
const 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 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, selectedRecordId])
const selected = selectedTemplate === undefined
? undefined
: currentRecord(selectedTemplate)
const selectedIndex = selected?.cell.index ?? null
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
collapseTurnRecords(allRecords, collapsedTurns),
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
const turnRecords = collapsedTurns.size === 0
? allRecords
: collapseTurnRecords(allRecords, collapsedTurns)
return collapsedAssistants.size === 0
? turnRecords
: collapseAssistantRecords(turnRecords, collapsedAssistants)
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
const 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 ? virtualRowStructure.length : 0,
enabled: virtualizationEnabled,
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 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() - (virtualItems.at(-1)?.end ?? 0))
const renderedRecords = virtualizationEnabled
? 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: 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
@@ -1576,20 +1741,25 @@ 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.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
), [allRecords, selectedRequest])
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
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
@@ -1605,9 +1775,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
@@ -1633,7 +1806,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)
@@ -1645,13 +1820,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
@@ -1670,7 +1849,7 @@ export function TrajectoryTable({
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(null)
}
@@ -1683,7 +1862,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))
@@ -1697,13 +1876,25 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(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
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)
}
@@ -1716,12 +1907,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')
}
@@ -1734,11 +1926,6 @@ export function TrajectoryTable({
// open its summary, and remember the row to scroll once the un-collapsed
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
@@ -1746,61 +1933,213 @@ 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 row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
const id = pendingScrollRecordId.current
if (id === null) return
const position = records.findIndex(record =>
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
if (position === -1) return
if (virtualizationEnabled) {
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
: rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`)
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const focusedPositions = records.flatMap((record, position) =>
record.collapsedSummary === undefined
&& record.cell.requestOnly !== true
&& timelineFocusIndexes.has(record.cell.index)
? [position]
: [])
const first = focusedPositions.at(0)
const last = focusedPositions.at(-1)
if (first === undefined || last === undefined) return
if (!virtualizationEnabled) {
const ledger = rootRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const firstRow = focusedRows.at(0)
const lastRow = focusedRows.at(-1)
if (firstRow === undefined || lastRow === undefined) return
const focusHeight =
lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top
const target = focusHeight > ledger.clientHeight
? firstRow
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (target !== undefined && typeof target.scrollIntoView === 'function') {
followsTableTail.current = false
target.scrollIntoView({
behavior: 'smooth',
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
})
}
return
}
const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => {
const record = records[position]
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
? firstVirtual
: focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)]
?? firstVirtual,
{
behavior: 'smooth',
align: focusHeight > paneHeight ? 'start' : 'center',
},
)
}, [
projectedVirtualRows,
records,
rowVirtualizer,
timelineFocusIndexes,
virtualIndexByRecordId,
virtualizationEnabled,
])
const requestOlder = useCallback((pane: HTMLDivElement) => {
if (
!hasOlderRecords
|| onLoadOlder === undefined
|| loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
) return
loadingOlder.current = true
setOlderLoading(true)
olderLoadAnchor.current = {
historyStartSeq,
scrollHeight: pane.scrollHeight,
scrollTop: pane.scrollTop,
}
void onLoadOlder().then((advanced) => {
if (!advanced) olderLoadAnchor.current = null
}).finally(() => {
loadingOlder.current = false
setOlderLoading(false)
})
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
if (!tableScrollInitialized.current) {
tableScrollInitialized.current = true
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
const anchor = olderLoadAnchor.current
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
if (!virtualizationEnabled) {
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
}
olderLoadAnchor.current = null
followsTableTail.current = false
return
}
if (followsTableTail.current) pane.scrollTop = pane.scrollHeight
}, [turns])
if (!tableScrollInitialized.current) {
if (historyLoading) return
tableScrollInitialized.current = true
followsTableTail.current = true
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
setTableScrollReady(true)
return
}
if (!followsTableTail.current) return
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
}, [
historyLoading,
historyStartSeq,
rowVirtualizer,
virtualRowStructure,
virtualizationEnabled,
])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
ref={tablePaneRef}
className={css.tablePane}
data-trajectory-scroll=""
onScroll={(event) => {
const pane = event.currentTarget
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane)
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
<table className={css.table}>
{showLoading && (
<div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel}
</span>
</div>
)}
<table
className={css.table}
data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length}
>
<colgroup>
<col className={css.eventColumn} />
<col className={css.contentColumn} />
</colgroup>
<tbody>
{records.map((record) => {
{virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualTop}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
const displayText = recordDisplayText(record.cell)
const toolCallOnly = isToolCallOnly(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
@@ -1824,15 +2163,15 @@ 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={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
@@ -1840,10 +2179,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}
@@ -1860,7 +2202,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) => {
@@ -1875,7 +2217,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
@@ -1894,7 +2236,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)
@@ -1917,9 +2259,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() }}
@@ -2013,8 +2354,8 @@ export function TrajectoryTable({
: `${listDisplayText}${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
@@ -2047,6 +2388,16 @@ export function TrajectoryTable({
</tr>
)
})}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualBottom}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
</tbody>
</table>
</div>
@@ -2141,7 +2492,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'
@@ -2220,7 +2571,10 @@ export function TrajectoryTable({
&& selectedRequestState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2371,7 +2725,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2388,7 +2745,10 @@ export function TrajectoryTable({
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<div
className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<MarkdownRecordContent
record={selected}
rendered
@@ -2406,7 +2766,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
@@ -2441,7 +2804,7 @@ export function TrajectoryTable({
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<span>Request #{selectedAssistantRequest ?? '—'}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -61,6 +61,46 @@
cursor: grabbing;
}
.earlierHistory {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 28px;
align-items: center;
justify-content: flex-start;
appearance: none;
box-sizing: border-box;
padding-left: 3px;
border: 0;
outline: none;
background: linear-gradient(
to right,
var(--dsw-alias-bg-layer-2) 0,
var(--dsw-alias-bg-layer-2) 38%,
transparent 100%
);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
line-height: 1;
opacity: 0.72;
cursor: pointer;
}
.earlierHistory:hover {
opacity: 1;
}
.earlierHistory[aria-disabled='true'] {
cursor: default;
}
.earlierHistory:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
}
.empty {
position: absolute;
top: 50%;
@@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
/** Whether the loaded timeline omits an earlier history prefix. */
hasEarlierRecords?: boolean
/** Load one earlier history page from the truncation control. */
onLoadEarlier?: () => Promise<boolean>
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
@@ -191,11 +195,49 @@ function LaneLabels() {
)
}
function EarlierHistoryBoundary({
loading,
onHover,
onLoad,
}: {
loading: boolean
onHover: () => void
onLoad: (() => void) | undefined
}) {
return (
<Tooltip
label={loading ? 'Loading earlier history…' : 'Click to load earlier history'}
side="right"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<button
type="button"
className={css.earlierHistory}
data-earlier-history
data-loading={loading || undefined}
aria-label={loading ? 'Loading earlier history' : 'Load earlier history'}
aria-disabled={loading || onLoad === undefined}
onClick={onLoad}
onPointerEnter={(event) => {
event.stopPropagation()
onHover()
}}
onPointerMove={(event) => { event.stopPropagation() }}
onPointerDown={(event) => { event.stopPropagation() }}
>
</button>
</Tooltip>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
hasEarlierRecords = false,
onLoadEarlier,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
@@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
@@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const showsEarlierBoundary = hasEarlierRecords
&& model !== null
&& domainStart === model.start
const loadEarlier = onLoadEarlier === undefined || loadingEarlier
? undefined
: () => {
setLoadingEarlier(true)
void onLoadEarlier().finally(() => { setLoadingEarlier(false) })
}
const projectedDomainStyle = model === null
? undefined
: {
@@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
{hasEarlierRecords && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
</div>
</div>
</section>
@@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
event.preventDefault()
}}
>
{showsEarlierBoundary && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
@@ -609,7 +675,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const widthPercent = Math.max(width * 100, 0.35)
const widthPercent = width * 100
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
@@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
@@ -17,15 +17,51 @@ import {
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
type TrajectoryTurnModel,
} from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
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
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) last = Math.max(last, cell.index)
}
}
return last
}
function timelineBlock(block: AssistantBlock): AssistantBlock {
switch (block.kind) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return {
kind: 'tool-call',
callId: block.callId,
name: block.name,
argsRaw: '',
}
case 'other': return { kind: 'other', block: null }
}
}
function partialStructureSignature(partial: ConversationSnapshot['partial']): string {
if (partial === null) return ''
return partial.blocks.map(block => block.kind === 'tool-call'
? `${block.kind}:${block.callId}:${block.name}`
: block.kind).join('\u0000')
}
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
@@ -33,7 +69,8 @@ export interface TrajectoryViewInjected {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void>
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
@@ -137,14 +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, loadAllHistory, setActualDuration, inspect, onInspectDone,
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)
@@ -154,26 +200,36 @@ export function TrajectoryView({
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
historyControllerRef.current = controller
void loadHistoryTailRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -183,18 +239,18 @@ export function TrajectoryView({
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of inspection.interruptedNodes) {
for (const node of interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
@@ -295,63 +351,74 @@ export function TrajectoryView({
})
}
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
const recorded = numbered.some(request =>
`${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn,
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return numbered
}, [
contexts, currentBranch.latest.prompt, nodes, partial, requests,
contexts, nodes, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
? null
: {
turn: partial.turn,
step: partial.step,
blocks: partial.blocks.map(block => timelineBlock(block)),
},
[partialStep, partialTurn, timelinePartialSignature])
const timelineTurns = useMemo(
() => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex),
[finalized, timelinePartial],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: 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 streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells),
),
[partialSearchTurns],
)
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(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
: trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode),
[timelineMode, timelineRange, timelineTurns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
@@ -361,31 +428,22 @@ export function TrajectoryView({
setTimelineSelection(null)
}
}, [timelineFocusIndexes])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const ledger = ledgerRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const first = focusedRows.at(0)
const last = focusedRows.at(-1)
if (first === undefined || last === undefined) return
const focusHeight =
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
if (focusHeight > ledger.clientHeight) {
if (typeof first.scrollIntoView === 'function') {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return
}
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [timelineFocusIndexes])
const 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
&&
@@ -396,23 +454,25 @@ 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: number[] = []
for (const turn of turns) {
const ids: string[] = []
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]
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
}, [turns])
}, [timelineTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -437,11 +497,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
})
}
@@ -458,6 +518,13 @@ export function TrajectoryView({
})
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return (
<div className={css.root} data-conversation-composer-overlay="">
<TrajectoryToolbar
@@ -479,45 +546,33 @@ export function TrajectoryView({
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
turns={timelineTurns}
mode={timelineMode}
range={timelineRange}
hasEarlierRecords={hasOlderHistory}
onLoadEarlier={loadEarlierHistory}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRangeChange={handleTimelineRangeChange}
onRecordSelect={handleTimelineRecordSelect}
onRecordFocus={handleTimelineRecordFocus}
/>
<div ref={ledgerRef} className={css.ledger}>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={turns}
turns={timelineTurns}
streamingCells={streamingCells}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
@@ -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),
@@ -35,7 +35,8 @@ export function apply(ctx: Context): void {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history, duration },
loadAllHistory: signal => history.loadAll(signal),
loadHistoryTail: signal => history.loadTail(signal),
loadOlderHistory: signal => history.loadOlder(signal),
setActualDuration: (value) => { duration.set(value) },
}
},
@@ -17,6 +17,7 @@ import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -475,6 +476,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
/**
* Append the changing in-flight assistant cells to a stable finalized layout.
* @param turns - Finalized layout derived with an empty-block partial anchor.
* @param partial - Current in-flight assistant projection.
* @param lastIndex - Highest cell index in the finalized layout.
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
*/
export function appendTrajectoryPartialLayout(
turns: readonly TrajectoryTurnModel[],
partial: ConversationSnapshot['partial'],
lastIndex: number,
): readonly TrajectoryTurnModel[] {
if (partial === null) return turns
const partialTurn = deriveTrajectoryLayout({
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
...partialTurn,
groups: partialTurn.groups.map(group => ({
...group,
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
})),
}
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
if (turnIndex === -1) return [...turns, streamed]
const current = turns[turnIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (current === undefined) return turns
const groups = [...current.groups]
for (const streamedGroup of streamed.groups) {
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
if (groupIndex === -1) {
groups.push(streamedGroup)
continue
}
const group = groups[groupIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (group === undefined) continue
const streamedCallIds = new Set(
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
)
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell =>
cell.requestOnly !== true
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
),
...streamedGroup.cells,
],
}
}
const updated = [...turns]
updated[turnIndex] = { ...current, groups }
return updated
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
@@ -542,9 +604,7 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
return formatElapsedSeconds(seconds)
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -566,6 +626,7 @@ function expandAssistant(
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
@@ -585,6 +646,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 !== ''
@@ -1,6 +1,7 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import { formatDurationMillis } from './trajectory-record.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
@@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
}
/**
* Format a timeline duration with a compact unit.
* Format a timeline duration as an integer-millisecond label.
* @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label.
* @returns Millisecond label with thousands separators.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
return formatDurationMillis(milliseconds)
}
function laneFor(kind: TrajectoryCellKind): number {
@@ -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
@@ -92,13 +94,32 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
}
/**
* Format own-duration for the trailing time column.
* 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 a duration in milliseconds with thousands separators.
* @param milliseconds - Duration in milliseconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
}
/**
* Format an elapsed duration given in seconds as a millisecond label.
* @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise a seconds label.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
return formatDurationMillis(seconds === null ? null : seconds * 1000)
}
@@ -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
}
@@ -10,17 +10,33 @@ import {
TrajectoryCell,
type TrajectoryCellKind,
} from '../src/client/TrajectoryCell.tsx'
import { formatDurationMillis } from '../src/client/trajectory-record.ts'
afterEach(cleanup)
describe('formatDurationMillis', () => {
it('formats exact millisecond labels with thousands separators', () => {
expect(formatDurationMillis(0)).toBe('0 ms')
expect(formatDurationMillis(29)).toBe('29 ms')
expect(formatDurationMillis(500)).toBe('500 ms')
expect(formatDurationMillis(1_500)).toBe('1,500 ms')
expect(formatDurationMillis(235_200)).toBe('235,200 ms')
expect(formatDurationMillis(null)).toBe('—')
expect(formatDurationMillis(Number.NaN)).toBe('—')
})
})
describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—')
expect(formatElapsedSeconds(235)).toBe('235 s')
expect(formatElapsedSeconds(235.0)).toBe('235 s')
expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
expect(formatElapsedSeconds(0)).toBe('0 s')
expect(formatElapsedSeconds(235)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.0)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.2)).toBe('235,200 ms')
expect(formatElapsedSeconds(235.25)).toBe('235,250 ms')
expect(formatElapsedSeconds(0)).toBe('0 ms')
expect(formatElapsedSeconds(0.029)).toBe('29 ms')
expect(formatElapsedSeconds(0.5)).toBe('500 ms')
expect(formatElapsedSeconds(1.5)).toBe('1,500 ms')
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
})
})
@@ -38,7 +54,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('#6')).toBeTruthy()
expect(screen.getByText('Tool')).toBeTruthy()
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
expect(screen.getByText('5 s')).toBeTruthy()
expect(screen.getByText('5,000 ms')).toBeTruthy()
})
it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -57,11 +73,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy()
expect(screen.getByText('235,200 ms')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235,200 ms'))
})
it('selected marks the row for the brand-primary inset ring', () => {
@@ -46,6 +46,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['react-dom', await import('react-dom')],
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])
@@ -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)
})
})
@@ -11,7 +11,9 @@ import type {
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
} from '../src/client/layout.ts'
afterEach(cleanup)
@@ -102,6 +104,70 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
const nodes = [{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'finalized' }],
}] as unknown as ConversationSnapshot['nodes']
const partial = {
turn: 2,
step: 1,
blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
}
const request = {
purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
runningCalls: [],
})
expect(base).toHaveLength(1)
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
expect(streamed[0]).toBe(base[0])
expect(streamed).toHaveLength(2)
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
index: 2,
kind: 'message',
text: 'streaming',
timeSeconds: null,
}])
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
})
it('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 },
@@ -141,7 +207,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2')
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
@@ -2,11 +2,15 @@
/** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
})
const TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
@@ -56,11 +60,33 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
collapsedAssistants: new Set<string>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows a muted placeholder for an assistant response containing only tool calls', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'Tool call only',
sourceBlocks: [{
type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
}],
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(screen.getByText('(tool call only)')).toBeTruthy()
})
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
@@ -71,6 +97,27 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('shows a tool record Duration as exact milliseconds', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
timeSeconds: 1.5,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
@@ -83,6 +130,17 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const panel = screen.getByRole('tabpanel')
expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1)
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
@@ -163,6 +221,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
@@ -210,6 +355,216 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(20)
})
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)
const view = render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
historyStartSeq={1}
hasOlderRecords
onLoadOlder={onLoadOlder}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
let scrollHeight = 200
Object.defineProperties(tablePane, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => scrollHeight },
})
tablePane.scrollTop = 0
fireEvent.scroll(tablePane)
fireEvent.scroll(tablePane)
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
resolveOlder?.(true)
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
scrollHeight = 260
view.rerender(
<TrajectoryTable
turns={[{
turn: 0,
groups: [{
title: 'Step 1',
cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
}],
}, ...TURNS]}
{...FOLD_PROPS}
historyStartSeq={0}
onLoadOlder={onLoadOlder}
/>,
)
expect(tablePane.scrollTop).toBe(60)
})
it('covers the ledger while the initial tail is loading', () => {
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
)
expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
})
it('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()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
})
const cells = Array.from({ length: 500 }, (_, index) => ({
index: index + 1,
kind: 'context' as const,
text: `Context ${index + 1}`,
timeSeconds: 0,
}))
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{ title: 'Context', cells }],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
await waitFor(() => {
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeGreaterThan(0)
})
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeLessThan(cells.length)
expect(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()
expect(screen.queryByText('Context 500')).toBeNull()
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 9_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(Number(view.container.querySelector(
'tr[data-virtual-position]',
)?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
})
expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
expect(screen.queryByText('Context 1')).toBeNull()
})
it('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', {
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()
@@ -73,6 +73,7 @@ function historySnapshot(
state: 'ready',
error: null,
hasMore: false,
baseSeq: nodes[0]?.seq ?? 0,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
@@ -89,11 +90,15 @@ function historySnapshot(
function standaloneHistory(
snapshot: SessionHistorySnapshot,
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
): Pick<
ComponentProps<typeof TrajectoryView>,
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadAllHistory: () => Promise.resolve(),
loadHistoryTail: () => Promise.resolve(),
loadOlderHistory: () => Promise.resolve(false),
}
}
@@ -145,13 +150,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
loadTail: loadHistoryTail,
loadOlder: loadOlderHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
@@ -167,7 +174,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadAllHistory }
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -201,7 +208,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
loadAllHistory: trajectory.loadAllHistory,
loadHistoryTail: trajectory.loadHistoryTail,
loadOlderHistory: trajectory.loadOlderHistory,
setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
@@ -295,9 +303,9 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
@@ -572,14 +580,53 @@ describe('timeline projection', () => {
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s')
expect(tooltip?.textContent).toContain('Total 2,000 ms')
expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s')
expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
} finally {
vi.useRealTimers()
}
})
it('marks an unloaded history prefix without inventing timeline duration', () => {
const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
const view = render(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
hasEarlierRecords
onLoadEarlier={onLoadEarlier}
onRangeChange={vi.fn()}
/>,
)
const boundary = screen.getByLabelText('Load earlier history')
expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
fireEvent.pointerEnter(boundary)
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
fireEvent.focus(boundary)
expect(screen.getByRole('tooltip').textContent)
.toContain('Click to load earlier history')
fireEvent.click(boundary)
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Loading earlier history')).toBeTruthy()
view.rerender(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
})
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
@@ -614,7 +661,35 @@ describe('timeline projection', () => {
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('clamp(0.25px, 0.8%, 1px)')
.toBe('min(0.8%, 1px)')
})
it('keeps dense sequence spans proportional before applying the pixel floor', () => {
const denseTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 400 }, (_, index) => ({
index,
kind: 'message' as const,
text: `message ${index}`,
timeSeconds: 1,
})),
}],
}]
const view = render(
<TrajectoryTimeline
turns={denseTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('min(0.02%, 1px)')
})
it('clears the selection without changing zoom on a zoomed right click', () => {
@@ -624,15 +699,18 @@ describe('timeline projection', () => {
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
hasEarlierRecords
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
expect(domainWidth).not.toBe('100%')
@@ -1065,7 +1143,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
@@ -1075,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,
@@ -1108,7 +1255,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
@@ -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',
)
})
})
+34 -14
View File
@@ -242,13 +242,14 @@ function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
baseSeq: number,
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
const original = events[originalSeq - baseSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
@@ -276,6 +277,7 @@ function planSurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -288,7 +290,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
return {
kind: 'replace',
seq: event.seq,
@@ -304,8 +306,9 @@ function applySurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events)
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -331,7 +334,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index, events)
const replacement = applySurfaceEvent(state, event, index, events, 0)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -341,38 +344,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
export class SurfaceManager implements SessionSurface {
/** Shared transition state; replacement history is not retained. */
private _state = createFoldState()
/** Last processed seq; -1 folds a seeded log on first access. */
private _lastProcessedSeq = -1
/** Last processed absolute seq. */
private _lastProcessedSeq: number
constructor(private log: readonly SessionEvent[]) {}
/**
* @param log - Contiguous complete log or loaded event window.
* @param baseSeq - Absolute sequence of the window's first event.
*/
constructor(
private log: readonly SessionEvent[],
private readonly baseSeq = 0,
) {
this._lastProcessedSeq = baseSeq - 1
}
/**
* Validate the next candidate without mutating the committed surface.
* @param event - candidate event that has not entered the log yet.
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length, this.log)
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
planSurfaceEvent(
this._state,
event,
this.baseSeq + this.log.length,
this.log,
this.baseSeq,
)
}
/** Monotonic count of folded positional replacements. */
get replaceGeneration(): number {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.replaceGeneration
}
/** Surface event sequences in model-visible order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.nodes
}
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
const tailSeq = this.baseSeq + this.log.length - 1
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
const index = seq - this.baseSeq
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
this._lastProcessedSeq = seq
}
}
}
@@ -9,6 +9,7 @@ import {
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { SurfaceManager } from '@deepseek-ai/dsh-session/surface'
import {
createMessage,
createToolResultMessage,
@@ -239,6 +240,53 @@ describe('foldSurface tool-result rewrites', () => {
})
describe('SurfaceManager', () => {
it('folds a contiguous window without materializing earlier event sequences', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
provenanceEvent(baseSeq + 1, undefined),
{
...provenanceEvent(baseSeq + 2, [baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
},
] as SessionEvent[]
const surface = new SurfaceManager(events, baseSeq)
expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1])
expect(surface.replaceGeneration).toBe(1)
})
it('validates tool-result rewrites against a nonzero window offset', () => {
const baseSeq = 400_000
const original = toolResultEvent(baseSeq, 'call')
const events: SessionEvent[] = [
original,
{
...original,
seq: baseSeq + 1,
time: baseSeq + 1,
surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
} as SessionEvent,
]
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 = Session.create(SessionId('shared-fold'))
s.append('user/message', createUserMessage({
+41
View File
@@ -1703,6 +1703,21 @@ importers:
micromark-extension-gfm:
specifier: ^3.0.0
version: 3.0.0
micromark-extension-math:
specifier: ^3.1.0
version: 3.1.0
micromark-factory-space:
specifier: ^2.0.1
version: 2.0.1
micromark-util-character:
specifier: ^2.1.1
version: 2.1.1
micromark-util-symbol:
specifier: ^2.0.1
version: 2.0.1
micromark-util-types:
specifier: ^2.0.2
version: 2.0.2
react:
specifier: ^18.2.0
version: 18.3.1
@@ -2059,6 +2074,9 @@ importers:
packages/client/ui-trajectory:
dependencies:
'@tanstack/react-virtual':
specifier: ^3.14.9
version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
diff:
specifier: ^9.0.0
version: 9.0.0
@@ -2081,12 +2099,18 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/react@18.3.31)
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/client/ui-workspace:
dependencies:
@@ -8887,6 +8911,15 @@ packages:
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
'@tanstack/react-virtual@3.14.9':
resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.7':
resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -13917,6 +13950,14 @@ snapshots:
estraverse: 5.3.0
picomatch: 4.0.4
'@tanstack/react-virtual@3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.17.7
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.17.7': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
+4
View File
@@ -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",
@@ -35,6 +37,7 @@
"apps/web/tests/web-search-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",
"apps/web/tests/math-rendering.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",
@@ -48,6 +51,7 @@
"apps/web/tests/chat-scroll-contract.e2e.ts",
"apps/web/tests/chat-long-interactions.e2e.ts",
"apps/web/tests/chat-continuous-conversation.e2e.ts",
"apps/web/tests/composer-tab-geometry.e2e.ts",
"apps/web/tests/complex-history.perf.ts",
"apps/web/tests/pwsh-terminal.e2e.ts",
"apps/web/stress-tests/reasoning-chunks.stress.ts",