Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
|
||||
README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1
|
||||
README.md: 2c737fe2d04df518e7d32d07aaede714a7a3566d
|
||||
README.zh.md: 738d6cafa0c6d44f05fecec01d565da91ef433e8
|
||||
|
||||
@@ -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; 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. 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
|
||||
|
||||
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred.
|
||||
- **In-flight Time stays blank** — `partial` and `runningCalls` rows show their running state without a fabricated duration, so the Overview renders a start marker rather than inventing a live span. Record and timeline selection are local to Trajectory, with no anchor deep links.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,4 +14,4 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **进行中时,Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
- **进行中时,Time 保持空白**:`partial` 与 `runningCalls` 行会显示运行状态,但不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度。记录选择与时间线选择位于 Trajectory 内部,不提供锚点深链接。
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,13 +35,16 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"diff": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@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:^",
|
||||
@@ -49,15 +53,15 @@
|
||||
"@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",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,12 +13,62 @@
|
||||
}
|
||||
|
||||
.tablePane {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
|
||||
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,
|
||||
@@ -27,7 +77,7 @@
|
||||
);
|
||||
|
||||
width: 100%;
|
||||
min-width: 480px;
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -77,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:
|
||||
@@ -89,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);
|
||||
}
|
||||
|
||||
@@ -98,13 +158,13 @@
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true'] td {
|
||||
height: 1px;
|
||||
height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
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;
|
||||
}
|
||||
@@ -123,10 +183,12 @@
|
||||
}
|
||||
|
||||
.requestBoundaryControl {
|
||||
--request-boundary-base-left: 12px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px));
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
@@ -191,6 +253,12 @@
|
||||
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.requestBoundaryControl[data-request-status='error']::before,
|
||||
.requestBoundaryControl[data-request-status='error']:hover::before,
|
||||
.requestBoundaryControl[data-request-status='error']:focus-visible::before {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.requestBoundaryControl:hover::after,
|
||||
.requestBoundaryControl:focus-visible::after {
|
||||
opacity: 1;
|
||||
@@ -277,7 +345,7 @@
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
display: inline-grid;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
@@ -292,8 +360,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turnLabelFull,
|
||||
.turnLabelCompact {
|
||||
display: none;
|
||||
grid-area: 1 / 1;
|
||||
max-width: 64px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turnLabelCompact {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.turnLabelActive {
|
||||
@@ -321,6 +399,13 @@
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.kindSlot :global([role='tooltip']) {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-left: 4px !important;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -343,15 +428,23 @@
|
||||
}
|
||||
|
||||
.kindTagIcon {
|
||||
display: none;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
width: 0;
|
||||
height: 13px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.kindTagLabel {
|
||||
display: inline;
|
||||
display: inline-block;
|
||||
max-width: 72px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table .kindSlot .message {
|
||||
@@ -370,7 +463,7 @@
|
||||
}
|
||||
|
||||
.requestBoundaryControl {
|
||||
left: 6px;
|
||||
--request-boundary-base-left: 6px;
|
||||
}
|
||||
|
||||
.kindSlot {
|
||||
@@ -386,19 +479,66 @@
|
||||
}
|
||||
|
||||
.kindTagIcon {
|
||||
display: inline-flex;
|
||||
width: 13px;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.kindTagLabel {
|
||||
display: none;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.turnLabelFull {
|
||||
display: none;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.turnLabelCompact {
|
||||
display: inline;
|
||||
max-width: 64px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.eventColumn,
|
||||
.event,
|
||||
.requestBoundaryControl,
|
||||
.kindSlot,
|
||||
.kindTag,
|
||||
.kindTagIcon,
|
||||
.kindTagLabel,
|
||||
.turnLabelFull,
|
||||
.turnLabelCompact {
|
||||
transition-duration: 180ms;
|
||||
transition-timing-function: var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.eventColumn,
|
||||
.kindSlot {
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
.event {
|
||||
transition-property: padding-right, padding-left;
|
||||
}
|
||||
|
||||
.requestBoundaryControl {
|
||||
transition-property: left;
|
||||
}
|
||||
|
||||
.kindTag {
|
||||
transition-property: padding-right, padding-left;
|
||||
}
|
||||
|
||||
.kindTagIcon {
|
||||
transition-property: width, opacity, transform;
|
||||
}
|
||||
|
||||
.kindTagLabel,
|
||||
.turnLabelFull,
|
||||
.turnLabelCompact {
|
||||
transition-property: max-width, opacity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,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;
|
||||
@@ -827,14 +971,17 @@
|
||||
.detailBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.detailBodySummary {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -851,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;
|
||||
}
|
||||
@@ -938,7 +1096,6 @@
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -948,7 +1105,6 @@
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@@ -989,7 +1145,7 @@
|
||||
margin: 0;
|
||||
padding: 0 0 3px 14px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -1163,9 +1319,19 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--dsw-font-family);
|
||||
gap: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thinkingChevron {
|
||||
flex: none;
|
||||
transition: transform 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.thinkingToggle[aria-expanded='true'] .thinkingChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.thinkingToggle:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Turn-aware trajectory event ledger with a local record inspector. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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,10 +19,20 @@ 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',
|
||||
user: 'USER',
|
||||
@@ -115,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'
|
||||
@@ -148,9 +183,8 @@ interface ToolCallTextParts {
|
||||
|
||||
interface SelectedRequest {
|
||||
turn: number | null
|
||||
section: number
|
||||
number: number
|
||||
group: string
|
||||
seq?: number
|
||||
}
|
||||
|
||||
interface DetailsResizeDrag {
|
||||
@@ -189,6 +223,20 @@ type TrajectorySplitStyle = CSSProperties & {
|
||||
'--trajectory-tool-request-width': string
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -222,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>
|
||||
@@ -232,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)
|
||||
}}
|
||||
>
|
||||
@@ -296,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. */
|
||||
@@ -306,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. */
|
||||
@@ -451,6 +514,22 @@ function indexRequestNumbers(
|
||||
return numbers
|
||||
}
|
||||
|
||||
function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap<number, number> {
|
||||
const indexes = new Map<number, number>()
|
||||
let runLength = 0
|
||||
for (const record of records) {
|
||||
if (record.cell.requestOnly === true) {
|
||||
indexes.set(record.cell.index, runLength++)
|
||||
continue
|
||||
}
|
||||
if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) {
|
||||
indexes.set(record.cell.index, runLength)
|
||||
}
|
||||
runLength = 0
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
function summarizeTurn(records: readonly TableRecord[]): string {
|
||||
const steps = new Set(
|
||||
records
|
||||
@@ -470,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
|
||||
@@ -528,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]
|
||||
@@ -1210,7 +1290,8 @@ function MarkdownRecordContent({
|
||||
aria-expanded={thinkingExpanded}
|
||||
onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
|
||||
>
|
||||
{thinkingExpanded ? 'Thinking' : 'Thinking ...'}
|
||||
Thinking
|
||||
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
|
||||
</button>
|
||||
{thinkingExpanded && (
|
||||
<MarkdownFragment
|
||||
@@ -1312,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>
|
||||
)
|
||||
}
|
||||
@@ -1496,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>
|
||||
)
|
||||
}
|
||||
@@ -1510,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,
|
||||
@@ -1523,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)
|
||||
@@ -1531,19 +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 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
|
||||
@@ -1552,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
|
||||
@@ -1581,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
|
||||
@@ -1609,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)
|
||||
@@ -1621,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
|
||||
@@ -1646,7 +1849,7 @@ export function TrajectoryTable({
|
||||
}
|
||||
|
||||
const clearInspectorSelection = () => {
|
||||
setSelectedIndex(null)
|
||||
setSelectedRecordId(null)
|
||||
setSelectedRequest(null)
|
||||
}
|
||||
|
||||
@@ -1659,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))
|
||||
@@ -1673,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)
|
||||
}
|
||||
@@ -1692,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')
|
||||
}
|
||||
|
||||
@@ -1710,8 +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 pendingScrollIndex = useRef<number | null>(null)
|
||||
const openRecordSummaryRef = useRef(openRecordSummary)
|
||||
openRecordSummaryRef.current = openRecordSummary
|
||||
useEffect(() => {
|
||||
@@ -1719,42 +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
|
||||
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 (!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'
|
||||
@@ -1767,20 +2152,26 @@ export function TrajectoryTable({
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(candidate => candidate.number === request)
|
||||
const requestStatus = requestInfo?.status
|
||||
?? (record.cell.isError === true ? 'error' : undefined)
|
||||
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
|
||||
const requestBoundaryStyle: RequestBoundaryStyle = {
|
||||
'--request-boundary-offset': `${requestRunIndex * 8}px`,
|
||||
}
|
||||
const requestLabel = request === undefined
|
||||
? undefined
|
||||
: `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
|
||||
@@ -1788,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}
|
||||
@@ -1808,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) => {
|
||||
@@ -1823,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
|
||||
@@ -1842,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)
|
||||
@@ -1858,13 +2252,15 @@ export function TrajectoryTable({
|
||||
aria-label={requestLabel}
|
||||
aria-pressed={requestSelected}
|
||||
data-label={requestLabel}
|
||||
data-request-run-index={requestRunIndex}
|
||||
data-request-status={requestStatus}
|
||||
style={requestBoundaryStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
turn: record.turn,
|
||||
section: record.section,
|
||||
number: request,
|
||||
group: record.group,
|
||||
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
|
||||
})
|
||||
}}
|
||||
onDoubleClick={(event) => { event.stopPropagation() }}
|
||||
@@ -1906,33 +2302,36 @@ export function TrajectoryTable({
|
||||
<span
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
>
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
>
|
||||
<span className={css.kindTagIcon} aria-hidden="true">
|
||||
{KIND_ICON[record.cell.kind]}
|
||||
</span>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1955,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 || '—'
|
||||
: (
|
||||
@@ -1989,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>
|
||||
@@ -2083,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'
|
||||
@@ -2162,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}>
|
||||
@@ -2313,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}>
|
||||
@@ -2330,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
|
||||
@@ -2348,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>
|
||||
@@ -2383,7 +2804,7 @@ export function TrajectoryTable({
|
||||
selectRequest(selectedAssistantRequestTarget)
|
||||
}}
|
||||
>
|
||||
<span>Request #{selectedAssistantRequestTarget.number}</span>
|
||||
<span>Request #{selectedAssistantRequest ?? '—'}</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
@@ -2470,7 +2891,7 @@ export function TrajectoryTable({
|
||||
)}
|
||||
{selectedAssistantRequestTarget !== undefined && (
|
||||
<OverviewSection
|
||||
label="Timing"
|
||||
label="Request Timing"
|
||||
onOpen={() => {
|
||||
selectRequest(selectedAssistantRequestTarget, 'timing')
|
||||
}}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
z-index: 1;
|
||||
isolation: isolate;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root :global([role='tooltip']) {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
@@ -53,6 +57,50 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.track[data-panning='true'] {
|
||||
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%;
|
||||
@@ -105,8 +153,15 @@
|
||||
.span {
|
||||
position: absolute;
|
||||
top: calc(var(--trajectory-span-lane) * 14px);
|
||||
left: calc(var(--trajectory-span-left) + 1px);
|
||||
width: max(2px, calc(var(--trajectory-span-width) - 2px));
|
||||
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
|
||||
width: max(
|
||||
2px,
|
||||
calc(
|
||||
var(--trajectory-span-width)
|
||||
- var(--trajectory-span-gap)
|
||||
- var(--trajectory-span-gap)
|
||||
)
|
||||
);
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
@@ -127,23 +182,35 @@
|
||||
}
|
||||
|
||||
.span[data-timeline-span='message'] {
|
||||
background: color-mix(
|
||||
--trajectory-assistant-decoding-color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='tool'] {
|
||||
background: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='subtool'] {
|
||||
background: color-mix(
|
||||
--trajectory-assistant-ttft-color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-label) 62%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
var(--trajectory-assistant-decoding-color) 54%,
|
||||
var(--dsw-alias-bg-layer-2)
|
||||
);
|
||||
|
||||
background: var(--trajectory-assistant-decoding-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span[data-timeline-span='message'][data-assistant-timing='true'] {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--trajectory-assistant-ttft-color) 0,
|
||||
var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft),
|
||||
var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft),
|
||||
var(--trajectory-assistant-decoding-color) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='tool'],
|
||||
.span[data-timeline-span='subtool'] {
|
||||
background: var(--dsw-alias-state-warn-label);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span[data-error='true'] {
|
||||
@@ -161,7 +228,7 @@
|
||||
|
||||
.span[data-hovered='true']:not([data-current='true']) {
|
||||
z-index: 1;
|
||||
opacity: 0.78;
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--dsw-alias-bg-layer-2),
|
||||
0 0 0 2px color-mix(
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {
|
||||
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||
type PointerEvent, type WheelEvent,
|
||||
type PointerEvent,
|
||||
} from 'react'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||
import {
|
||||
deriveTrajectoryTimeline,
|
||||
formatTimelineOffset,
|
||||
@@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4
|
||||
const EDGE_PAN_ZONE_FRACTION = 0.08
|
||||
const EDGE_PAN_STEP_FRACTION = 0.025
|
||||
const MAXIMUM_EDGE_PAN_PX = 32
|
||||
const TIMELINE_TOOLTIP_DELAY_MS = 500
|
||||
|
||||
interface TimelineRecordDetail {
|
||||
decodingMs?: number
|
||||
durationMs?: number
|
||||
startedAt?: number
|
||||
ttftMs?: number
|
||||
}
|
||||
|
||||
interface FractionRange {
|
||||
start: number
|
||||
@@ -29,11 +39,103 @@ interface HoverPoint {
|
||||
recordIndex: number | null
|
||||
}
|
||||
|
||||
interface PanGesture {
|
||||
anchorClientX: number
|
||||
anchorStart: number
|
||||
moved: boolean
|
||||
pannable: boolean
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
function assistantTimingDetail(
|
||||
metrics: AssistantMetricDetail | undefined,
|
||||
): Pick<TimelineRecordDetail, 'ttftMs' | 'decodingMs'> {
|
||||
const start = metrics?.stepStartTime
|
||||
const first = metrics?.firstTokenTime
|
||||
const completed = metrics?.completedTime
|
||||
if (
|
||||
metrics?.timingRecorded !== true
|
||||
|| typeof start !== 'number'
|
||||
|| typeof first !== 'number'
|
||||
|| typeof completed !== 'number'
|
||||
|| !Number.isFinite(start)
|
||||
|| !Number.isFinite(first)
|
||||
|| !Number.isFinite(completed)
|
||||
|| first < start
|
||||
|| completed < first
|
||||
) return {}
|
||||
return { ttftMs: first - start, decodingMs: completed - first }
|
||||
}
|
||||
|
||||
function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail {
|
||||
const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
|
||||
? undefined
|
||||
: Math.max(0, cell.timeSeconds * 1_000)
|
||||
const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt)
|
||||
? undefined
|
||||
: cell.startedAt
|
||||
return {
|
||||
...(durationMs === undefined ? {} : { durationMs }),
|
||||
...(startedAt === undefined ? {} : { startedAt }),
|
||||
...assistantTimingDetail(cell.assistantMetrics),
|
||||
}
|
||||
}
|
||||
|
||||
function timelineKindLabel(kind: TrajectoryCellKind): string {
|
||||
switch (kind) {
|
||||
case 'system': return 'SYSTEM'
|
||||
case 'user': return 'USER'
|
||||
case 'context': return 'CONTEXT'
|
||||
case 'compacted': return 'COMPACTED'
|
||||
case 'message': return 'ASSISTANT'
|
||||
case 'tool': return 'TOOL'
|
||||
case 'subtool': return 'SUBTOOL'
|
||||
}
|
||||
}
|
||||
|
||||
function formatRecordedTime(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
})
|
||||
}
|
||||
|
||||
function timelineTooltipLabel(
|
||||
kind: TrajectoryCellKind,
|
||||
detail: TimelineRecordDetail | undefined,
|
||||
): string {
|
||||
const heading = timelineKindLabel(kind)
|
||||
if (detail === undefined) return heading
|
||||
const duration = detail.durationMs === undefined
|
||||
? null
|
||||
: `Total ${formatTimelineOffset(detail.durationMs)}`
|
||||
const range = detail.startedAt === undefined
|
||||
? null
|
||||
: detail.durationMs === undefined
|
||||
? `Started ${formatRecordedTime(detail.startedAt)}`
|
||||
: `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime(
|
||||
detail.startedAt + detail.durationMs,
|
||||
)}`
|
||||
const segments = detail.ttftMs === undefined || detail.decodingMs === undefined
|
||||
? null
|
||||
: `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset(
|
||||
detail.decodingMs,
|
||||
)}`
|
||||
const timing = [duration, segments].filter(value => value !== null).join(' · ')
|
||||
return [heading, range, timing].filter(value => value !== null && value !== '').join('\n')
|
||||
}
|
||||
|
||||
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
||||
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
|
||||
@@ -70,11 +172,17 @@ function rangeFraction(
|
||||
range: TrajectoryTimeRange,
|
||||
start: number,
|
||||
duration: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): FractionRange {
|
||||
return orderedRange(
|
||||
clampFraction((range.start - start) / duration),
|
||||
clampFraction((range.end - start) / duration),
|
||||
const bounded = orderedRange(
|
||||
Math.min(maximum, Math.max(minimum, range.start)),
|
||||
Math.min(maximum, Math.max(minimum, range.end)),
|
||||
)
|
||||
return {
|
||||
start: (bounded.start - start) / duration,
|
||||
end: (bounded.end - start) / duration,
|
||||
}
|
||||
}
|
||||
|
||||
function LaneLabels() {
|
||||
@@ -87,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,
|
||||
@@ -99,14 +245,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
onRecordFocus,
|
||||
}: TrajectoryTimelineProps) {
|
||||
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||
const durationByIndex = useMemo(
|
||||
const detailByIndex = useMemo(
|
||||
() => new Map(turns.flatMap(turn =>
|
||||
turn.groups.flatMap(group =>
|
||||
group.cells.flatMap(cell =>
|
||||
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
|
||||
? []
|
||||
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
|
||||
),
|
||||
group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const),
|
||||
),
|
||||
)),
|
||||
[turns],
|
||||
@@ -117,8 +259,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
anchorClientX: number
|
||||
recordIndex: number | null
|
||||
} | null>(null)
|
||||
const panRef = useRef<PanGesture | null>(null)
|
||||
const rootRef = useRef<HTMLElement | null>(null)
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -174,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
|
||||
: {
|
||||
@@ -183,20 +339,59 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
} as CSSProperties
|
||||
const committed = model === null || range === null
|
||||
? null
|
||||
: rangeFraction(range, domainStart, domainDuration)
|
||||
: rangeFraction(range, domainStart, domainDuration, model.start, model.end)
|
||||
const draftFraction = model === null || draft === null
|
||||
? null
|
||||
: rangeFraction(draft, domainStart, domainDuration)
|
||||
: rangeFraction(draft, domainStart, domainDuration, model.start, model.end)
|
||||
const visibleRange = draftFraction ?? committed
|
||||
const activeRange = draft ?? range
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (root === null) return
|
||||
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
const track = trackRef.current
|
||||
if (track === null || model === null) return
|
||||
setAnimateViewport(false)
|
||||
const rect = track.getBoundingClientRect()
|
||||
const anchorFraction =
|
||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
if (nextDuration >= fullDuration * 0.999) {
|
||||
setViewport(null)
|
||||
return
|
||||
}
|
||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||
const nextStart = Math.min(
|
||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||
model.end - nextDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||
}
|
||||
root.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { root.removeEventListener('wheel', onWheel) }
|
||||
}, [domainDuration, domainStart, fullDuration, mode, model])
|
||||
|
||||
if (model === null) {
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<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>
|
||||
@@ -227,6 +422,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button === 2) {
|
||||
panRef.current = {
|
||||
anchorClientX: event.clientX,
|
||||
anchorStart: domainStart,
|
||||
moved: false,
|
||||
pannable: viewport !== null,
|
||||
pointerId: event.pointerId,
|
||||
}
|
||||
if (viewport !== null) setAnimateViewport(false)
|
||||
setPanning(true)
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.button !== 0) return
|
||||
const anchor = fractionAt(event)
|
||||
const anchorTime = domainStart + anchor * domainDuration
|
||||
@@ -245,10 +455,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
}
|
||||
|
||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const fraction = fractionAt(event)
|
||||
setHover({ fraction, recordIndex: recordIndexAt(event) })
|
||||
const pan = panRef.current
|
||||
if (pan !== null && pan.pointerId === event.pointerId) {
|
||||
if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) {
|
||||
pan.moved = true
|
||||
}
|
||||
if (!pan.pannable) return
|
||||
const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width)
|
||||
const nextStart = Math.min(
|
||||
Math.max(pan.anchorStart - delta * domainDuration, model.start),
|
||||
model.end - domainDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + domainDuration })
|
||||
return
|
||||
}
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
let nextDomainStart = domainStart
|
||||
if (viewport !== null) {
|
||||
@@ -286,6 +510,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
}
|
||||
|
||||
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const pan = panRef.current
|
||||
if (pan !== null && pan.pointerId === event.pointerId) {
|
||||
const moved = pan.moved
|
||||
|| Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX
|
||||
panRef.current = null
|
||||
setPanning(false)
|
||||
if (!moved) onRangeChange(null)
|
||||
return
|
||||
}
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
const pointFraction = fractionAt(event)
|
||||
@@ -335,41 +568,20 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
|
||||
const onPointerCancel = () => {
|
||||
dragRef.current = null
|
||||
panRef.current = null
|
||||
setDraft(null)
|
||||
setHover(null)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
setAnimateViewport(false)
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchorFraction =
|
||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
if (nextDuration >= fullDuration * 0.999) {
|
||||
setViewport(null)
|
||||
return
|
||||
}
|
||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||
const nextStart = Math.min(
|
||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||
model.end - nextDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||
setPanning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={css.track}
|
||||
data-panning={panning || undefined}
|
||||
aria-label="Timeline overview; drag horizontally to focus events"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -378,20 +590,23 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerLeave={() => {
|
||||
if (dragRef.current === null) setHover(null)
|
||||
if (dragRef.current === null && panRef.current === null) setHover(null)
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setAnimateViewport(false)
|
||||
onRangeChange(null)
|
||||
setViewport(null)
|
||||
}}
|
||||
>
|
||||
{showsEarlierBoundary && (
|
||||
<EarlierHistoryBoundary
|
||||
loading={loadingEarlier}
|
||||
onHover={() => { setHover(null) }}
|
||||
onLoad={loadEarlier}
|
||||
/>
|
||||
)}
|
||||
{hover !== null && hover.recordIndex === null && draft === null && (
|
||||
<div
|
||||
className={css.hoverLine}
|
||||
@@ -451,7 +666,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
className={css.lanes}
|
||||
data-animate-viewport={animateViewport || undefined}
|
||||
data-timeline-domain
|
||||
aria-hidden="true"
|
||||
style={projectedDomainStyle}
|
||||
>
|
||||
{model.spans
|
||||
@@ -461,34 +675,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
.map((span) => {
|
||||
const left = (span.start - model.start) / fullDuration
|
||||
const width = (span.end - span.start) / fullDuration
|
||||
const durationMs = durationByIndex.get(span.index)
|
||||
const widthPercent = width * 100
|
||||
const detail = detailByIndex.get(span.index)
|
||||
const ttftMs = detail?.ttftMs
|
||||
const decodingMs = detail?.decodingMs
|
||||
const ttftFraction = ttftMs === undefined
|
||||
|| decodingMs === undefined
|
||||
|| ttftMs + decodingMs <= 0
|
||||
? null
|
||||
: ttftMs / (ttftMs + decodingMs)
|
||||
return (
|
||||
<span
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
data-timeline-record-index={span.index}
|
||||
data-error={span.isError || undefined}
|
||||
data-equal-duration={mode === 'time' || undefined}
|
||||
data-current={span.index === selectedIndex || undefined}
|
||||
data-hovered={hover?.recordIndex === span.index || undefined}
|
||||
data-search-match={searchMatchIndexes === null
|
||||
? undefined
|
||||
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
||||
data-selected={activeRange === null
|
||||
? undefined
|
||||
: span.start <= activeRange.end && span.end >= activeRange.start
|
||||
? 'true'
|
||||
: 'false'}
|
||||
<Tooltip
|
||||
key={span.index}
|
||||
title={durationMs === undefined
|
||||
? span.label
|
||||
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
|
||||
style={{
|
||||
'--trajectory-span-left': `${left * 100}%`,
|
||||
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
|
||||
'--trajectory-span-lane': span.lane,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
label={timelineTooltipLabel(span.kind, detail)}
|
||||
side="bottom"
|
||||
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
data-timeline-record-index={span.index}
|
||||
data-assistant-timing={ttftFraction === null ? undefined : 'true'}
|
||||
data-error={span.isError || undefined}
|
||||
data-equal-duration={mode === 'time' || undefined}
|
||||
data-current={span.index === selectedIndex || undefined}
|
||||
data-hovered={hover?.recordIndex === span.index || undefined}
|
||||
data-search-match={searchMatchIndexes === null
|
||||
? undefined
|
||||
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
||||
data-selected={activeRange === null
|
||||
? undefined
|
||||
: span.start <= activeRange.end && span.end >= activeRange.start
|
||||
? 'true'
|
||||
: 'false'}
|
||||
style={{
|
||||
'--trajectory-span-left': `${left * 100}%`,
|
||||
'--trajectory-span-width': `${widthPercent}%`,
|
||||
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
|
||||
'--trajectory-span-lane': span.lane,
|
||||
...(ttftFraction === null
|
||||
? {}
|
||||
: { '--trajectory-assistant-ttft': `${ttftFraction * 100}%` }),
|
||||
} as CSSProperties}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
.action:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@@ -159,11 +159,6 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
|
||||
@@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps {
|
||||
actualDuration: boolean
|
||||
/** Select recorded-duration or equal-width blocks. */
|
||||
onActualDurationChange: (actualDuration: boolean) => void
|
||||
/** Whether recorded timing retains idle gaps between user turns. */
|
||||
/** Whether recorded timing retains idle gaps between operations. */
|
||||
actualTime: boolean
|
||||
/** Select complete wall-clock timing or idle-compressed timing. */
|
||||
onActualTimeChange: (actualTime: boolean) => void
|
||||
/** Number of turns containing more than one row. */
|
||||
collapsibleTurns: number
|
||||
/** Whether every collapsible turn is currently folded. */
|
||||
allTurnsCollapsed: boolean
|
||||
/** Fold or expand every collapsible turn. */
|
||||
onToggleAllTurns: () => void
|
||||
/** Number of assistant messages followed by tool calls. */
|
||||
collapsibleAssistants: number
|
||||
/** Whether every collapsible assistant's tool calls are currently folded. */
|
||||
allAssistantsCollapsed: boolean
|
||||
/** Fold or expand tool calls under every collapsible assistant. */
|
||||
@@ -40,10 +36,8 @@ export function TrajectoryToolbar({
|
||||
onActualDurationChange,
|
||||
actualTime,
|
||||
onActualTimeChange,
|
||||
collapsibleTurns,
|
||||
allTurnsCollapsed,
|
||||
onToggleAllTurns,
|
||||
collapsibleAssistants,
|
||||
allAssistantsCollapsed,
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
@@ -91,7 +85,6 @@ export function TrajectoryToolbar({
|
||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
aria-pressed={allTurnsCollapsed}
|
||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
disabled={collapsibleTurns === 0}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
@@ -105,7 +98,6 @@ export function TrajectoryToolbar({
|
||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
aria-pressed={allAssistantsCollapsed}
|
||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
disabled={collapsibleAssistants === 0}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
|
||||
@@ -4,8 +4,8 @@ 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,
|
||||
SessionHistoryFace,
|
||||
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
|
||||
SessionHistoryFace, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
@@ -17,20 +17,62 @@ 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 'image': return block
|
||||
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 }
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
hooks: {
|
||||
history: SessionHistoryFace
|
||||
duration: SnapshotStore<boolean>
|
||||
}
|
||||
loadHistoryTail: (signal: AbortSignal) => Promise<void>
|
||||
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -133,43 +175,62 @@ 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, loadAllHistory, 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, setActualDuration] = useState(false)
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
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),
|
||||
@@ -179,18 +240,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) {
|
||||
@@ -291,63 +352,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 (
|
||||
@@ -357,31 +429,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
|
||||
&&
|
||||
@@ -392,23 +455,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))
|
||||
|
||||
@@ -433,11 +498,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
|
||||
})
|
||||
}
|
||||
@@ -454,8 +519,15 @@ 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}>
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
actualDuration={actualDuration}
|
||||
onActualDurationChange={(nextActualDuration) => {
|
||||
@@ -467,55 +539,41 @@ export function TrajectoryView({
|
||||
setActualTime(nextActualTime)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
collapsibleTurns={collapsibleTurnIds.length}
|
||||
allTurnsCollapsed={allTurnsCollapsed}
|
||||
onToggleAllTurns={toggleAllTurns}
|
||||
collapsibleAssistants={collapsibleAssistantIds.length}
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
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),
|
||||
|
||||
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal file
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createSnapshotStore, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Create the browser-wide trajectory duration preference source.
|
||||
* @returns a persisted source shared by every session view in one plugin lifecycle.
|
||||
*/
|
||||
export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
|
||||
return createSnapshotStore(false, {
|
||||
persist: { name: 'dsh.trajectory.duration' },
|
||||
})
|
||||
}
|
||||
@@ -7,16 +7,11 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.view' slot is declared by
|
||||
* ui-conversation's apply (which then provides the service), and register()
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
/** Required services: the conversation view slot and independent history source. */
|
||||
export const inject = ['slots', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -24,7 +19,8 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.slots.register({
|
||||
const duration = createTrajectoryDurationStore()
|
||||
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
@@ -32,9 +28,11 @@ export function apply(ctx: Context): void {
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
hooks: { history },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
hooks: { history, duration },
|
||||
loadHistoryTail: signal => history.loadTail(signal),
|
||||
loadOlderHistory: signal => history.loadOlder(signal),
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
}, TrajectoryView))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -75,7 +76,7 @@ const PREVIEW_OUTPUT_CHARACTERS = 512
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
{ kind: 'user' | 'context' }
|
||||
>
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
@@ -325,19 +326,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
const { node, nodeIndex: i } = entry
|
||||
if (node.kind === 'user' || node.kind === 'steering') {
|
||||
if (node.kind === 'user') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
const turn = node.kind === 'steering'
|
||||
? node.turn
|
||||
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'user',
|
||||
...inputCellDetail(node),
|
||||
opensTurn: node.kind === 'user',
|
||||
opensTurn: true,
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
@@ -453,7 +452,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
else for (const laid of laidList) pushMessage(call.turn, laid)
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
// Orphan turn-0 cells (orphaned tools) fold into Turn 1.
|
||||
const prologue = turns.get(0)
|
||||
if (prologue !== undefined) {
|
||||
turns.delete(0)
|
||||
@@ -475,6 +474,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 +602,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 +624,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 +644,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 !== ''
|
||||
@@ -739,7 +799,7 @@ function stringifySourceValue(value: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn that encloses a user/message: next assistant/steering turn, else the
|
||||
* Turn that encloses a user/message: next assistant turn, else the
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
*/
|
||||
function enclosingUserTurn(
|
||||
@@ -752,7 +812,7 @@ function enclosingUserTurn(
|
||||
const n = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (n === undefined) continue
|
||||
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
|
||||
if (n.kind === 'assistant') return n.turn
|
||||
}
|
||||
if (partial !== null) return partial.turn
|
||||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||||
@@ -776,7 +836,7 @@ function firstVisibleTurn(
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const turns = nodes.flatMap(node =>
|
||||
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
|
||||
node.kind === 'assistant' && node.turn > 0
|
||||
? [node.turn]
|
||||
: [],
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
@@ -116,14 +115,9 @@ export function deriveTrajectoryTimeline(
|
||||
function deriveTimedTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
actualDuration: boolean,
|
||||
removeUserIdle: boolean,
|
||||
compressIdle: boolean,
|
||||
): TrajectoryTimelineModel | null {
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
let removedUserIdle = 0
|
||||
let previousTurnEnd: number | null = null
|
||||
|
||||
for (const turn of turns) {
|
||||
const timedTurns = turns.flatMap((turn) => {
|
||||
const rawSpans = turn.groups.flatMap(group =>
|
||||
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
||||
if (cell.requestOnly === true) return []
|
||||
@@ -140,30 +134,43 @@ function deriveTimedTimeline(
|
||||
}]
|
||||
}),
|
||||
)
|
||||
if (rawSpans.length === 0) continue
|
||||
return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
|
||||
})
|
||||
const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
|
||||
if (rawSpans.length === 0) return null
|
||||
|
||||
const turnStart = Math.min(...rawSpans.map(span => span.start))
|
||||
const turnEnd = Math.max(...rawSpans.map(span => span.end))
|
||||
if (removeUserIdle && previousTurnEnd !== null) {
|
||||
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
|
||||
const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
|
||||
let removedIdle = 0
|
||||
let coveredUntil: number | null = null
|
||||
for (const span of [...rawSpans].sort((left, right) =>
|
||||
left.start - right.start || left.end - right.end)) {
|
||||
if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
|
||||
removedIdle += span.start - coveredUntil
|
||||
}
|
||||
spans.push(...rawSpans.map(span => ({
|
||||
...span,
|
||||
start: span.start - removedUserIdle,
|
||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
||||
})))
|
||||
removedIdleBySpan.set(span, removedIdle)
|
||||
coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
|
||||
}
|
||||
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
for (const turn of timedTurns) {
|
||||
const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
|
||||
const offset = removedIdleBySpan.get(span) ?? 0
|
||||
return {
|
||||
...span,
|
||||
start: span.start - offset,
|
||||
end: (actualDuration ? span.end : span.start) - offset,
|
||||
}
|
||||
})
|
||||
spans.push(...projected)
|
||||
if (turn.turn !== null) {
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: turnStart - removedUserIdle,
|
||||
time: Math.min(...projected.map(span => span.start)),
|
||||
})
|
||||
}
|
||||
previousTurnEnd = previousTurnEnd === null
|
||||
? turnEnd
|
||||
: Math.max(previousTurnEnd, turnEnd)
|
||||
}
|
||||
|
||||
if (spans.length === 0) return null
|
||||
return {
|
||||
start: Math.min(...spans.map(span => span.start)),
|
||||
end: Math.max(...spans.map(span => span.end)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,23 +13,16 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
/* Trajectory keeps the ledger and details panel inside the remaining
|
||||
* conversation height. Only the ledger pane scrolls; the composer remains
|
||||
* the fixed flex sibling below this view. */
|
||||
:global([data-conversation-scroll]):has(.root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]):has(.root) > :first-child {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
/* ConversationRoot publishes the floating composer's live height. */
|
||||
--dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -38,15 +38,16 @@ describe('tsdown client artifact', () => {
|
||||
async function loadArtifact() {
|
||||
let handoff: Handoff | undefined
|
||||
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
// The implied-eval ban targets accidental string execution, not this
|
||||
// deliberate built-bundle fixture running in the window scope.
|
||||
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
|
||||
new Function(code!)()
|
||||
expect(handoff).toBeDefined()
|
||||
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')],
|
||||
])
|
||||
const surface = handoff!.factory((spec) => {
|
||||
@@ -60,7 +61,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
|
||||
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
@@ -72,10 +73,8 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge and
|
||||
// 'sessionHistory' for its per-session history source; this bench
|
||||
// supplies both.
|
||||
ctx.provide('conversation', {})
|
||||
// The plugin reads sessionHistory for its per-session history source;
|
||||
// slot availability is tracked by slots.inject.
|
||||
ctx.provide('sessionHistory', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
|
||||
@@ -13,6 +13,8 @@ const checkpoint = {
|
||||
time: 100,
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: null,
|
||||
} as ConversationNode
|
||||
|
||||
const abandoned = {
|
||||
@@ -71,6 +73,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 +88,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,21 +60,64 @@ 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' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
|
||||
|
||||
expect(screen.getByText('500 ms')).toBeTruthy()
|
||||
expect(screen.getByText('1.00 s')).toBeTruthy()
|
||||
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[] = [{
|
||||
@@ -101,10 +159,13 @@ describe('TrajectoryTable', () => {
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText(thinking)).toBeNull()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
|
||||
})
|
||||
|
||||
@@ -160,6 +221,350 @@ 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
|
||||
let scrollHeight = 200
|
||||
Object.defineProperties(tablePane, {
|
||||
clientHeight: { configurable: true, get: () => 100 },
|
||||
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||
})
|
||||
tablePane.scrollTop = 100
|
||||
fireEvent.scroll(tablePane)
|
||||
|
||||
scrollHeight = 260
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[...TURNS, {
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
|
||||
}],
|
||||
}]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
expect(tablePane.scrollTop).toBe(260)
|
||||
|
||||
tablePane.scrollTop = 20
|
||||
fireEvent.scroll(tablePane)
|
||||
scrollHeight = 320
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[...TURNS, {
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
|
||||
{ index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
|
||||
],
|
||||
}],
|
||||
}]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
@@ -175,17 +580,79 @@ describe('TrajectoryTable', () => {
|
||||
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders responsive role icons with a custom tooltip', () => {
|
||||
it('marks failed requests and lays coincident request markers left to right', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 3,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 3,
|
||||
kind: 'message',
|
||||
text: 'Recovered response',
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
const failed = screen.getByRole('button', { name: 'Request #1' })
|
||||
const retry = screen.getByRole('button', { name: 'Request #2' })
|
||||
const recovered = screen.getByRole('button', { name: 'Request #3' })
|
||||
expect(failed.getAttribute('data-request-status')).toBe('error')
|
||||
expect(failed.getAttribute('data-request-run-index')).toBe('0')
|
||||
expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
|
||||
expect(retry.getAttribute('data-request-run-index')).toBe('1')
|
||||
expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
|
||||
expect(recovered.getAttribute('data-request-run-index')).toBe('2')
|
||||
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
|
||||
})
|
||||
|
||||
it('shows the custom role tooltip only from the responsive icon', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
|
||||
const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
|
||||
|
||||
expect(toolTag).not.toBeNull()
|
||||
expect(toolTag?.getAttribute('title')).toBeNull()
|
||||
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
|
||||
expect(toolIcon).toBeTruthy()
|
||||
|
||||
fireEvent.mouseEnter(toolTag as HTMLElement)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
|
||||
fireEvent.mouseLeave(toolTag as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseEnter(toolIcon as HTMLElement)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip.textContent).toBe('TOOL')
|
||||
expect(tooltip.getAttribute('data-side')).toBe('right')
|
||||
fireEvent.mouseLeave(toolIcon as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -21,7 +21,10 @@ import type {
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import {
|
||||
ConversationSession, ConversationSessionHeader,
|
||||
type ConversationSessionHeaderProps, type ConversationSessionProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
@@ -31,15 +34,13 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
|
||||
import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
// Stub of the conversation package's standard locale seat (this spec mounts
|
||||
// its ConversationSession chrome); answers from the zh dictionary and falls
|
||||
// back to the key like the real chain.
|
||||
const tConversation: ConversationSessionProps['t'] =
|
||||
const tConversation: ConversationSessionHeaderProps['t'] =
|
||||
key => (conversationZh as Record<string, string>)[key] ?? key
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
@@ -72,6 +73,7 @@ function historySnapshot(
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
baseSeq: nodes[0]?.seq ?? 0,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
@@ -88,11 +90,25 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneDuration(): Pick<
|
||||
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
|
||||
> {
|
||||
const duration = createSnapshotStore(false)
|
||||
return {
|
||||
useDuration: bindSnapshotSelector(duration),
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +123,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -134,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({
|
||||
@@ -150,13 +168,10 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
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). */
|
||||
@@ -165,7 +180,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
.map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
|
||||
}
|
||||
|
||||
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
|
||||
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
@@ -175,6 +190,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
const views = {
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}
|
||||
const useInput = bindSnapshotSelector(createSnapshotStore({
|
||||
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
|
||||
})) as never
|
||||
const inputActions = {
|
||||
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
|
||||
}
|
||||
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
||||
// render it with the session standard kit (what SlotOutlet does for a
|
||||
// list-kind session slot, minus machinery).
|
||||
@@ -187,12 +213,16 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
const injectedProps = 'hooks' in injected
|
||||
? {
|
||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||
useHistory: bindSnapshotSelector(
|
||||
(injected as TrajectoryViewInjected).hooks.history,
|
||||
),
|
||||
}
|
||||
? (() => {
|
||||
const trajectory = injected as TrajectoryViewInjected
|
||||
return {
|
||||
loadHistoryTail: trajectory.loadHistoryTail,
|
||||
loadOlderHistory: trajectory.loadOlderHistory,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
}
|
||||
})()
|
||||
: injected
|
||||
return (
|
||||
<View
|
||||
@@ -203,32 +233,40 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
)
|
||||
}) as unknown as ConversationSessionProps['renderSlot']
|
||||
return render(
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
t={tConversation}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
views={{
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
useInput={bindSnapshotSelector(createSnapshotStore({
|
||||
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
|
||||
})) as never}
|
||||
inputActions={{
|
||||
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
|
||||
}}
|
||||
bindDraftMirror={() => () => {}}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
<>
|
||||
<ConversationSessionHeader
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={() => null}
|
||||
views={views}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
open={vi.fn()}
|
||||
t={tConversation}
|
||||
/>
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
views={views}
|
||||
releaseSessionImages={vi.fn()}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
bindDraftMirror={() => () => {}}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,6 +284,24 @@ describe('plugin registration', () => {
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
})
|
||||
|
||||
it('shares one browser-wide duration preference across session injections', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.slots.entries('conversation.view')
|
||||
.find(candidate => candidate.options.id === 'trajectory')
|
||||
expect(entry).toBeDefined()
|
||||
const injectEntry = entry!.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
) => TrajectoryViewInjected
|
||||
const first = injectEntry(SID)
|
||||
const second = injectEntry('s2' as SessionId)
|
||||
|
||||
expect(second.hooks.duration).toBe(first.hooks.duration)
|
||||
first.setActualDuration(true)
|
||||
expect(second.hooks.duration.getSnapshot()).toBe(true)
|
||||
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
@@ -261,15 +317,16 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
|
||||
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||
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)
|
||||
@@ -418,7 +475,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
.toBe('outside')
|
||||
fireEvent.contextMenu(plot)
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBeNull()
|
||||
.toBe('outside')
|
||||
})
|
||||
|
||||
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
|
||||
@@ -465,6 +522,12 @@ describe('tab switching in ConversationRoot', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||
name: 'Collapse turns',
|
||||
}).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||
name: 'Collapse calls',
|
||||
}).disabled).toBe(false)
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
})
|
||||
@@ -495,6 +558,242 @@ describe('timeline projection', () => {
|
||||
}],
|
||||
}] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={[{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: 'assistant',
|
||||
startedAt: 1_000,
|
||||
timeSeconds: 2,
|
||||
assistantMetrics: {
|
||||
timingRecorded: true,
|
||||
stepStartTime: 1_000,
|
||||
firstTokenTime: 1_500,
|
||||
completedTime: 3_000,
|
||||
usageProvided: false,
|
||||
outputTokens: null,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}]}
|
||||
mode="duration"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const span = view.container.querySelector<HTMLElement>(
|
||||
'[data-timeline-span="message"]',
|
||||
)
|
||||
expect(span?.getAttribute('title')).toBeNull()
|
||||
expect(span?.getAttribute('data-assistant-timing')).toBe('true')
|
||||
expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
|
||||
|
||||
fireEvent.mouseEnter(span as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
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,000 ms')
|
||||
expect(tooltip?.textContent).toContain('TTFT 500 ms')
|
||||
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
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
|
||||
expect(fireEvent.wheel(screen.getByText('Input'), {
|
||||
clientX: 20,
|
||||
deltaY: -100,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('scales sequence gutters with narrow operation spans', () => {
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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('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', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
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%')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
|
||||
expect(onRangeChange).toHaveBeenCalledOnce()
|
||||
expect(onRangeChange).toHaveBeenCalledWith(null)
|
||||
expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
|
||||
})
|
||||
|
||||
it('clears the selection and suppresses the context menu at full zoom', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={{ start: 2, end: 4 }}
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(onRangeChange).toHaveBeenCalledOnce()
|
||||
expect(onRangeChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={{ start: 2, end: 4 }}
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
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 })
|
||||
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
|
||||
const before = domain?.style.getPropertyValue('--trajectory-domain-left')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(plot.getAttribute('data-panning')).toBe('true')
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
|
||||
|
||||
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
|
||||
expect(onRangeChange).not.toHaveBeenCalled()
|
||||
expect(plot.getAttribute('data-panning')).toBeNull()
|
||||
})
|
||||
|
||||
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
@@ -547,7 +846,7 @@ describe('timeline projection', () => {
|
||||
|
||||
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
render(
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
@@ -565,13 +864,26 @@ describe('timeline projection', () => {
|
||||
for (let index = 0; index < 24; index++) {
|
||||
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
||||
}
|
||||
const draftSelection = view.container.querySelectorAll<HTMLElement>(
|
||||
'[data-dragging="true"]',
|
||||
)
|
||||
expect(draftSelection).toHaveLength(2)
|
||||
for (const overlay of draftSelection) {
|
||||
expect(Number.parseFloat(
|
||||
overlay.style.getPropertyValue('--trajectory-selection-left'),
|
||||
)).toBeLessThan(0)
|
||||
}
|
||||
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
||||
|
||||
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
||||
| { start: number; end: number }
|
||||
| undefined
|
||||
const fullRange = deriveTrajectoryTimeline(longTurns)
|
||||
expect(selectedRange).toBeDefined()
|
||||
expect(fullRange).not.toBeNull()
|
||||
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
||||
expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
|
||||
expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
|
||||
})
|
||||
|
||||
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||
@@ -662,6 +974,53 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
|
||||
const separatedTurns = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
||||
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
|
||||
start: 1_000,
|
||||
end: 4_000,
|
||||
spans: [
|
||||
{ index: 1, start: 1_000, end: 2_000 },
|
||||
{ index: 2, start: 2_000, end: 3_000 },
|
||||
{ index: 3, start: 3_000, end: 4_000 },
|
||||
],
|
||||
turnBoundaries: [
|
||||
{ turn: 1, time: 1_000 },
|
||||
{ turn: 2, time: 3_000 },
|
||||
],
|
||||
})
|
||||
expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
|
||||
start: 1_000,
|
||||
end: 41_000,
|
||||
spans: [
|
||||
{ index: 1, start: 1_000, end: 2_000 },
|
||||
{ index: 2, start: 4_000, end: 5_000 },
|
||||
{ index: 3, start: 40_000, end: 41_000 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('projects between-turn compaction without inventing a turn boundary', () => {
|
||||
const withStandaloneCompaction = [
|
||||
{
|
||||
@@ -707,6 +1066,7 @@ describe('timeline projection', () => {
|
||||
{
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
...standaloneDuration(),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
@@ -715,6 +1075,38 @@ describe('timeline projection', () => {
|
||||
})
|
||||
|
||||
describe('TrajectoryView branches', () => {
|
||||
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||
const firstDuration = createTrajectoryDurationStore()
|
||||
const commonProps = {
|
||||
...standaloneProps(NODES),
|
||||
...standaloneHistory(historySnapshot(NODES)),
|
||||
}
|
||||
const first = render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
useDuration={bindSnapshotSelector(firstDuration)}
|
||||
setActualDuration={(value) => { firstDuration.set(value) }}
|
||||
/>,
|
||||
)
|
||||
const duration = screen.getByRole('button', { name: 'Use actual duration' })
|
||||
|
||||
expect(duration.getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.click(duration)
|
||||
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||
first.unmount()
|
||||
|
||||
const restoredDuration = createTrajectoryDurationStore()
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
useDuration={bindSnapshotSelector(restoredDuration)}
|
||||
setActualDuration={(value) => { restoredDuration.set(value) }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
|
||||
.toBe('true')
|
||||
})
|
||||
|
||||
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
||||
const retained = {
|
||||
kind: 'user',
|
||||
@@ -770,8 +1162,10 @@ describe('TrajectoryView branches', () => {
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -781,6 +1175,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,
|
||||
@@ -812,8 +1274,10 @@ describe('TrajectoryView branches', () => {
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal file
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Measurable virtual-row grouping and durable identity contracts. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
|
||||
import {
|
||||
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
||||
type VirtualizableTrajectoryRecord,
|
||||
} from '../src/client/trajectory-virtual-rows.ts'
|
||||
|
||||
function record(
|
||||
index: number,
|
||||
cell: Partial<TrajectoryCellProps> = {},
|
||||
collapsedSummaryKind?: 'turn' | 'assistant',
|
||||
): VirtualizableTrajectoryRecord {
|
||||
return {
|
||||
cell: {
|
||||
index,
|
||||
kind: 'message',
|
||||
text: `record ${index}`,
|
||||
timeSeconds: 0,
|
||||
...cell,
|
||||
},
|
||||
...(collapsedSummaryKind === undefined ? {} : { collapsedSummaryKind }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory virtual rows', () => {
|
||||
it('groups zero-height request boundaries with the following content row', () => {
|
||||
const first = record(1, { requestOnly: true, sourceSeq: 10 })
|
||||
const second = record(2, { requestOnly: true, sourceSeq: 11 })
|
||||
const content = record(3, { sourceSeq: 12 })
|
||||
|
||||
expect(groupTrajectoryVirtualRows([first, second, content])).toEqual([{
|
||||
entries: [
|
||||
{ logicalIndex: 0, record: first },
|
||||
{ logicalIndex: 1, record: second },
|
||||
{ logicalIndex: 2, record: content },
|
||||
],
|
||||
height: 30,
|
||||
key: trajectoryVirtualRecordKey(content),
|
||||
}])
|
||||
})
|
||||
|
||||
it('retains terminal request-boundary clearance as a measurable row', () => {
|
||||
const content = record(1, { sourceSeq: 10 })
|
||||
const boundary = record(2, { requestOnly: true, sourceSeq: 11 })
|
||||
const rows = groupTrajectoryVirtualRows([content, boundary])
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[1]).toEqual({
|
||||
entries: [{ logicalIndex: 1, record: boundary }],
|
||||
height: 9,
|
||||
key: trajectoryVirtualRecordKey(boundary),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the rendered collapsed-summary height', () => {
|
||||
const summary = record(1, { sourceSeq: 10 }, 'turn')
|
||||
|
||||
expect(groupTrajectoryVirtualRows([summary])[0]?.height).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps an existing row key stable when older history is prepended', () => {
|
||||
const existing = record(2, { sourceSeq: 100 })
|
||||
const prepended = record(1, { sourceSeq: 10 })
|
||||
|
||||
const before = groupTrajectoryVirtualRows([existing])[0]?.key
|
||||
const after = groupTrajectoryVirtualRows([prepended, existing])[1]?.key
|
||||
|
||||
expect(after).toBe(before)
|
||||
})
|
||||
|
||||
it('keeps the content key when a request boundary joins its row', () => {
|
||||
const content = record(2, { sourceSeq: 100 })
|
||||
const boundary = record(1, { requestOnly: true, sourceSeq: 99 })
|
||||
|
||||
expect(groupTrajectoryVirtualRows([boundary, content])[0]?.key)
|
||||
.toBe(groupTrajectoryVirtualRows([content])[0]?.key)
|
||||
})
|
||||
|
||||
it('distinguishes a folded summary from its source record', () => {
|
||||
const source = record(1, { sourceSeq: 10 })
|
||||
const summary = record(1, { sourceSeq: 10 }, 'assistant')
|
||||
|
||||
expect(trajectoryVirtualRecordKey(summary)).not.toBe(trajectoryVirtualRecordKey(source))
|
||||
})
|
||||
|
||||
it('exposes a DOM-safe semantic key', () => {
|
||||
const source = record(1, { callId: 'call with spaces/and?punctuation' })
|
||||
|
||||
expect(trajectoryVirtualRecordKey(source)).toBe(
|
||||
'message%00call%00call%20with%20spaces%2Fand%3Fpunctuation',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user