feat(trajectory): virtualize long session histories

This commit is contained in:
_Kerman
2026-08-04 13:57:19 +08:00
parent 26742effdd
commit ae1008b4fb
26 changed files with 966 additions and 198 deletions

View File

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

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; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Workspace and Session lists

View File

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

View File

@@ -17,11 +17,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. */

View File

@@ -36,7 +36,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 +72,34 @@ 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. */
/**
* 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()
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/** Rebuild the tail for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
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
}
}
/**
@@ -161,7 +157,6 @@ export class SessionHistorySource implements SessionHistoryFace {
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
@@ -234,7 +229,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
@@ -339,8 +334,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.inspectionCache = { entries: this.entries, 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
@@ -382,7 +376,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()
}

View File

@@ -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,7 +30,16 @@ 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().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)
@@ -42,7 +51,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 +69,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 +141,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 +161,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 +170,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)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11
README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad
README.md: 18f8e637c0588a45c2bf33f7c8dbc36aee5def22
README.zh.md: f81482cb4de7d5a76220145e99acde3aa4e33d17

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. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
## 模型体验

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",

View File

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

View File

@@ -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,
@@ -23,6 +24,12 @@ import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48
const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_ROW_HEIGHT_PX = 30
const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9
const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
@@ -195,6 +202,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,
@@ -312,6 +329,16 @@ 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 history node, 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. */
@@ -399,6 +426,11 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
})
}
function virtualRecordHeight(record: TableRecord, final: boolean): number {
if (record.cell.requestOnly !== true) return VIRTUAL_ROW_HEIGHT_PX
return final ? VIRTUAL_FINAL_REQUEST_HEIGHT_PX : 0
}
function filterRecords(
records: readonly TableRecord[],
matches: ReadonlySet<number>,
@@ -492,7 +524,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
@@ -552,7 +583,6 @@ function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
): TableRecord[] {
if (collapsedAssistants.size === 0) return [...records]
const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
@@ -1538,6 +1568,11 @@ export function TrajectoryTable({
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
recordFocus = null,
historyLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1554,18 +1589,65 @@ 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 pendingScrollIndex = useRef<number | null>(null)
const loadingOlder = useRef(false)
const [olderLoading, setOlderLoading] = useState(false)
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(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 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 virtualizationEnabled = records.length > VIRTUALIZATION_THRESHOLD
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: virtualizationEnabled ? records.length : 0,
enabled: virtualizationEnabled,
estimateSize: (index) => {
const record = records[index]
return record === undefined
? VIRTUAL_ROW_HEIGHT_PX
: virtualRecordHeight(record, index === records.length - 1)
},
getItemKey: (index) => {
const record = records[index]
return record === undefined
? index
: `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`
},
getScrollElement: () => tablePaneRef.current,
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
overscan: VIRTUAL_OVERSCAN_ROWS,
})
const virtualRows = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualRows[0]?.start ?? 0
const virtualBottom = virtualRows.length === 0
? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0))
const renderedRecords = virtualizationEnabled
? virtualRows.flatMap((row) => {
const record = records[row.index]
return record === undefined ? [] : [{ record, position: row.index }]
})
: records.map((record, position) => ({ record, position }))
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
@@ -1697,7 +1779,13 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
pendingScrollIndex.current = recordSelection.index
}, [recordSelection, selectRecord])
useEffect(() => {
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
appliedRecordFocus.current = recordFocus
pendingScrollIndex.current = recordFocus.index
}, [recordFocus])
const selectRequest = (
request: SelectedRequest,
@@ -1734,11 +1822,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(() => {
@@ -1752,27 +1835,122 @@ export function TrajectoryTable({
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const position = records.findIndex(record =>
record.cell.index === index && record.collapsedSummary === undefined)
if (position === -1) return
pendingScrollIndex.current = null
if (virtualizationEnabled) {
rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' })
return
}
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
/* 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, 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') {
target.scrollIntoView({
behavior: 'smooth',
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
})
}
return
}
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
let focusHeight = 0
for (let position = first; position <= last; position++) {
const record = records[position]
if (record === undefined) continue
focusHeight += virtualRecordHeight(
record,
position === records.length - 1,
)
}
rowVirtualizer.scrollToIndex(
focusHeight > paneHeight ? first : focusedPositions[Math.floor((focusedPositions.length - 1) / 2)] ?? first,
{
behavior: 'smooth',
align: focusHeight > paneHeight ? 'start' : 'center',
},
)
}, [records, rowVirtualizer, timelineFocusIndexes, 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) {
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, turns, virtualizationEnabled])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
@@ -1784,23 +1962,48 @@ export function TrajectoryTable({
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}
>
<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 }) => {
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'
@@ -1840,6 +2043,7 @@ 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-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
@@ -2013,8 +2217,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 +2251,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>

View File

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

View File

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

View File

@@ -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,7 +17,10 @@ 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,
@@ -27,13 +30,45 @@ import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = 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 {
hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void>
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
@@ -138,7 +173,8 @@ function searchMatches(
}
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 [collapsedAssistants, setCollapsedAssistants] =
@@ -154,26 +190,35 @@ 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 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 +228,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,47 +340,44 @@ 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 turns = useMemo(
() => appendTrajectoryPartialLayout(finalized.turns, partial, finalized.lastIndex),
[finalized, partial],
)
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'
@@ -350,8 +392,8 @@ export function TrajectoryView({
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,29 +403,6 @@ 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 collapsibleTurnIds = useMemo(
() => turns
.filter(turn =>
@@ -458,6 +477,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,9 +505,11 @@ export function TrajectoryView({
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
turns={timelineTurns}
mode={timelineMode}
range={timelineRange}
hasEarlierRecords={hasOlderHistory}
onLoadEarlier={loadEarlierHistory}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
@@ -494,21 +522,12 @@ export function TrajectoryView({
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' })
}
setTimelineRecordFocus({ index })
}}
/>
<div ref={ledgerRef} className={css.ledger}>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
requestNumbers={requestNumbers}
@@ -518,6 +537,11 @@ export function TrajectoryView({
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={nodes.at(0)?.seq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

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

View File

@@ -475,6 +475,61 @@ 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
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell => cell.requestOnly !== true),
...streamedGroup.cells,
],
}
}
const updated = [...turns]
updated[turnIndex] = { ...current, groups }
return updated
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
@@ -566,6 +621,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

View File

@@ -47,6 +47,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')],
])

View File

@@ -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,42 @@ 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('omits duration when node times are missing instead of rendering NaN', () => {
const nodes = [
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },

View File

@@ -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,
@@ -61,6 +65,28 @@ const FOLD_PROPS = {
}
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' })
@@ -210,6 +236,109 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(20)
})
it('loads one older page at the top and preserves the visible anchor', 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}
hasOlderRecords
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('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(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('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()

View File

@@ -89,11 +89,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 +149,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 +173,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 +207,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 +302,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)
@@ -580,6 +587,45 @@ describe('timeline projection', () => {
}
})
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 +660,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 +698,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 +1142,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))}
/>,
)
@@ -1108,7 +1186,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))}
/>,
)