Merge remote-tracking branch 'origin/master' into worktree-renameweb
# Conflicts: # packages/client/runtime/README.i18n.yaml # packages/client/test-runtime/src/sessions.ts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
|
||||
2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Trajectory inspection ledger
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state.
|
||||
|
||||
## Decision
|
||||
|
||||
**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.**
|
||||
|
||||
- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests.
|
||||
- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
|
||||
- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
|
||||
- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
|
||||
- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer.
|
||||
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative.
|
||||
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
|
||||
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
|
||||
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
|
||||
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
|
||||
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers.
|
||||
|
||||
**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower.
|
||||
|
||||
**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows.
|
||||
|
||||
**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
|
||||
|
||||
**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context.
|
||||
|
||||
**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces.
|
||||
|
||||
## Consequences
|
||||
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:轨迹检查记录表
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-trajectory-inspection-ledger.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。
|
||||
|
||||
## 决策
|
||||
|
||||
**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
|
||||
|
||||
- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。
|
||||
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
|
||||
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
|
||||
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
|
||||
- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
|
||||
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。
|
||||
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
|
||||
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
|
||||
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
|
||||
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
|
||||
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。
|
||||
|
||||
**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
|
||||
|
||||
**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。
|
||||
|
||||
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
|
||||
|
||||
**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。
|
||||
|
||||
**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。
|
||||
|
||||
## 后果
|
||||
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md
|
||||
2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c
|
||||
2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Local JSON tree renderer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-local-json-tree-renderer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The read-only JSON inspector used by the [trajectory ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) needs compact object and array previews, explicit array paths for copy actions, fixed-open and collapsible root modes, and keyboard navigation. `react-json-view-lite` exposes neither custom node rendering nor row identity, so satisfying those requirements through that dependency requires a package-manager patch against compiled distribution files and DOM traversal that reconstructs data paths from visible labels. The patch behaves as an untyped fork while its source maps and upstream source remain unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
`JsonTree` owns its recursive presentation in `dsh-client-ui-primitives`.
|
||||
|
||||
- Each rendered row receives its value and property path directly. Object keys and array indexes extend that path during recursion, so copy actions never recover application data from rendered DOM text.
|
||||
- Expandable rows render the compact preview locally and mount child rows only while expanded. `expandTopLevel` selects between a fixed-open bracket frame and a collapsible root node without changing the public component contract.
|
||||
- The tree keeps one tabbable expander among visible nodes. Pointer activation claims that tab stop; Up and Down move it cyclically, while Left and Right collapse or expand the focused node.
|
||||
- `react-json-view-lite` is not a package dependency and has no pnpm patch. Focused component tests pin previews, expansion, keyboard focus, and array copy paths.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the distribution patch.** Rejected because the application-specific renderer and array identity contract would remain hidden in generated third-party files, and every dependency update would require reviewing a fork without matching source maps.
|
||||
|
||||
**Use the upstream renderer without previews.** Rejected because `{…}` and `[…]` discard the compact payload context that the trajectory inspector uses for scanning.
|
||||
|
||||
**Inject previews and row metadata after render.** Rejected because effects or mutation observers would depend on the same private DOM structure while splitting one row between React ownership and imperative mutation.
|
||||
|
||||
**Adopt a larger JSON viewer.** Rejected because editing, search, and theme systems are outside the current read-only contract; the added dependency surface would not remove the inspector-specific copy and layout code.
|
||||
|
||||
## Consequences
|
||||
|
||||
The JSON inspector has one source-level owner, explicit data flow, accurate array paths, and no patched dependency. The package now owns recursive rendering, expansion state, ARIA tree structure, and roving focus behavior, so changes to those semantics require focused component coverage. The implementation remains intentionally read-only and limited to the preview, navigation, and copy behavior used by current consumers.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note:本地 JSON 树渲染器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-local-json-tree-renderer.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[轨迹检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)使用的只读 JSON 检查器需要提供紧凑的对象和数组预览、供复制操作使用的明确数组路径、固定展开与可折叠两种根节点模式,以及键盘导航。`react-json-view-lite` 既不提供自定义节点渲染,也不提供行标识;要通过该依赖满足这些要求,就必须使用包管理器为编译后的发布文件打补丁,并遍历 DOM,从可见标签中还原数据路径。该补丁实际上相当于一个不受类型约束的 fork,但其源码映射与上游源码均未同步修改。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-client-ui-primitives` 中的 `JsonTree` 自行负责递归呈现。
|
||||
|
||||
- 每个渲染行都直接接收自身的值和属性路径。递归时,对象键和数组索引会附加到路径末尾,因此复制操作无需再从 DOM 渲染文本中反向还原应用数据。
|
||||
- 可展开行在本地渲染紧凑预览,仅在展开时挂载子行。`expandTopLevel` 可选择固定展开的括号框架或可折叠根节点,而不改变组件的公开契约。
|
||||
- 树中所有可见节点仅保留一个可通过 Tab 键聚焦的展开控件。使用指针激活控件后,该控件会成为 Tab 键焦点位置;上、下方向键循环移动焦点,左、右方向键折叠或展开当前焦点所在节点。
|
||||
- `react-json-view-lite` 不在 `dsh-client-ui-primitives` 的依赖项中,也没有 pnpm 补丁。针对性组件测试锁定预览、展开、键盘焦点和数组复制路径。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留发布文件补丁。** 不予采纳:应用专用的渲染逻辑和数组标识契约仍会隐藏在生成的第三方文件中;每次更新依赖,都必须评审一个没有配套源码映射的 fork。
|
||||
|
||||
**直接使用不提供预览的上游渲染器。** 不予采纳:`{…}` 和 `[…]` 会丢失轨迹检查器扫读时所需的紧凑载荷上下文。
|
||||
|
||||
**渲染后注入预览和行元数据。** 不予采纳:Effect 或 MutationObserver 仍会依赖同一套私有 DOM 结构,同时使 React 和命令式变更机制分别负责同一行的不同部分。
|
||||
|
||||
**采用更大型的 JSON 查看器。** 不予采纳:编辑、搜索与主题系统不在当前只读契约范围内;扩大依赖范围也无法去除检查器专用的复制和布局代码。
|
||||
|
||||
## 后果
|
||||
|
||||
JSON 检查器在源码层由单一实现负责,具有显式数据流和准确的数组路径,且没有经过补丁修改的依赖。`dsh-client-ui-primitives` 负责递归渲染、展开状态、ARIA 树结构和焦点循环移动行为,因此修改这些语义时必须提供针对性组件测试。实现有意保持只读,仅包含当前消费方使用的预览、导航与复制行为。
|
||||
@@ -5,8 +5,9 @@
|
||||
// the code-variant parent row titled by the model-authored description, its
|
||||
// three always-visible nested sub-rows (bash through the sample registration,
|
||||
// read through GenericToolCard, the failing read wearing the error state),
|
||||
// the expanded program body, inert bash / file-link sub-row gestures, and
|
||||
// the trajectory/waterfall tabs' sub-call cells and timing lanes.
|
||||
// the expanded program body, inert bash / file-link sub-row gestures,
|
||||
// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call
|
||||
// cells and timing overview.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -197,7 +198,7 @@ it('expands the code row into the program body; sub-row clicks do not open detai
|
||||
`)
|
||||
})
|
||||
|
||||
it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
|
||||
it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -208,51 +209,30 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
|
||||
}, { timeout: 10_000 })
|
||||
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
|
||||
expect({
|
||||
// Three Sub cells nested under the run_code Tool cell, in dispatch order,
|
||||
// each with a real +N.Ns own-duration off the start/settle pair (the
|
||||
// fixture spaces every event 800ms apart — never the em dash).
|
||||
// Three Subtool cells nested under the run_code Tool cell in dispatch
|
||||
// order, each paired with its result preview.
|
||||
subCells: subCells.map(cell => visibleText(cell)),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"subCells": [
|
||||
"#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
|
||||
"#50Subread · {"path":"notes/demo.txt"}+0.8s",
|
||||
"#51Subread · {"path":"notes/missing.txt"}+0.8s",
|
||||
"SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
|
||||
"SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
|
||||
"SUBTOOLread{"path":"notes/missing.txt"}→error",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
// Waterfall: each sub-call draws a measured lane scaled into the parent
|
||||
// turn's dispatch window.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-subspan]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const lanes = [...document.querySelectorAll('[data-subspan]')]
|
||||
const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
|
||||
expect({
|
||||
lanes: lanes.map(lane => ({
|
||||
label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
|
||||
title: lane.querySelector('[data-timing]')?.getAttribute('title'),
|
||||
timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
|
||||
})),
|
||||
count: timelineSubCalls.length,
|
||||
measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"lanes": [
|
||||
{
|
||||
"label": "bash",
|
||||
"timing": "measured",
|
||||
"title": "bash · 0.80s",
|
||||
},
|
||||
{
|
||||
"label": "read",
|
||||
"timing": "measured",
|
||||
"title": "read · 0.80s",
|
||||
},
|
||||
{
|
||||
"label": "read",
|
||||
"timing": "measured",
|
||||
"title": "read · 0.80s",
|
||||
},
|
||||
"count": 3,
|
||||
"measured": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -118,14 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => {
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
// Tool rows no longer open details; the column stays width 0.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
// Tool rows do not drive layout geometry; the Session's default panel stays open.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
|
||||
// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture
|
||||
// rendered purely from the log (the seeded-history pattern: zero model calls
|
||||
// in replay, so every surface here is the client fold + host history RPC,
|
||||
// not replay binding). The seed is recorded live under the standard
|
||||
// discipline: turn 1 produces a bash call plus two parallel reads in one
|
||||
// assistant message (tool-call density for the trajectory/waterfall lanes),
|
||||
// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes).
|
||||
// Web e2e scenarios: navigation & panes — the Trajectory view and timing
|
||||
// overview, its local details inspector, and sidebar search, all over ONE rich
|
||||
// two-turn seeded fixture rendered purely from the log (the seeded-history
|
||||
// pattern: zero model calls in replay, so every surface here is the client
|
||||
// fold + host history RPC, not replay binding). The seed is recorded live
|
||||
// under the standard discipline: turn 1 produces a bash call plus two
|
||||
// parallel reads in one assistant message (tool-call density for the
|
||||
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -23,7 +23,6 @@ import { saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
@@ -39,6 +38,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let slotErrors: string[]
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
@@ -58,6 +58,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
slotErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
|
||||
slotErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
@@ -123,55 +129,68 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
// Two sticky turn sections; turn 1's step group summarizes its tool mix
|
||||
// (bash + the two parallel reads collapse to 'bash read×2').
|
||||
await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.waitForTimeout(100)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({
|
||||
pageErrors: [],
|
||||
slotErrors: [],
|
||||
warnings: [],
|
||||
})
|
||||
// Turn rules partition the ledger without restoring a separate header row.
|
||||
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
||||
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.locator('tr[data-kind="tool"]').first().click()
|
||||
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.getByRole('tab', { name: 'Result' }).click()
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('complementary', { name: 'Event details' })
|
||||
.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
|
||||
await page.getByRole('tab', { name: 'Waterfall' }).click()
|
||||
// The stats header rides the waterfall body. The span fold counts THREE
|
||||
// spans for this two-turn log: only assistant/steering nodes carry a turn
|
||||
// number, so the first user message lands in a turn-0 prologue span (a
|
||||
// P-I placeholder shape — pinned as-is; real spans are deferred to
|
||||
// P-III per the view's deviation ledger). Calls: bash + two reads.
|
||||
await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
|
||||
// One lane per span, tagged by turn number, prologue included.
|
||||
for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
|
||||
await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
}
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
|
||||
const before = await page.locator('tr[data-kind]').count()
|
||||
const box = await plot.boundingBox()
|
||||
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
||||
await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
|
||||
await plot.click({ button: 'right' })
|
||||
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await bashRow.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// The card's own controls are outside the summary row and must not open
|
||||
// details either — the terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// Read summaries are host-open file links; they also must not open details.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
|
||||
@@ -257,9 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(slotErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'terminal-card.expected.md',
|
||||
'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = rows[0]?.parentElement ?? null
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
|
||||
@@ -139,10 +139,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// capture; still zero model calls.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// Path label survives from the recorded args (a.txt).
|
||||
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -357,16 +357,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
requireDist()
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
|
||||
// temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
|
||||
// and tsconfig paths pointed at explicitly.
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// the global Harness home inside the temp world; tsx also needs the repo's
|
||||
// loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
@@ -440,17 +444,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '04-round-complete')
|
||||
}, 150_000)
|
||||
|
||||
it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
|
||||
it('view tabs: Chat and Trajectory switch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
|
||||
await page.locator('button', { hasText: /Trajectory/i }).first().click()
|
||||
await screen(page, '05-trajectory-tab')
|
||||
await page.locator('button', { hasText: /Waterfall/i }).first().click()
|
||||
await screen(page, '06-waterfall-tab')
|
||||
await page.getByLabel('Trajectory timeline').waitFor()
|
||||
await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0)
|
||||
await page.locator('button', { hasText: /^Chat$/i }).first().click()
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => {
|
||||
it('5 bash differential rendering: tool row click leaves the default details column open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -462,11 +466,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
const toolRow = page.locator('[data-sample="bash-global"]')
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays open.
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
await screen(page, '09-details-open')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,13 +12,13 @@
|
||||
{"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -30,6 +30,6 @@
|
||||
{"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
|
||||
{"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}}
|
||||
{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -25,9 +25,9 @@
|
||||
{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -38,9 +38,9 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -51,6 +51,6 @@
|
||||
{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"2c594e5a-bbcc-4c64-b5ee-8e84eb5dd949"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"e5d8d5fb-f555-4fda-948f-9ab178145929"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_BYXlxjFaalMg95YVqEeF2495"},"content":[{"type":"tool-result","toolCallId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false}],"role":"user","id":"215febf9-ff3c-42b8-93b7-27ce1505c1c2"}},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -26,6 +26,6 @@
|
||||
{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"61e83a98-18a9-4f1f-9151-c8783cc39901"}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"ee28d2ee-08b2-4ed7-a1e9-84865f55e2af"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,6 +12,6 @@
|
||||
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2101eadc-3475-4c77-ae0a-0107b12c34bc"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- paragraph: partial
|
||||
- text: 已停止
|
||||
- button "复制":
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"dc9a43b2-63a3-49bc-97e9-9dfb6465f6c1"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,6 +12,6 @@
|
||||
{"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3396618c-56a2-4f97-a9c7-2a3cbb16a3c8"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"7dcfe547-5555-4e5d-b2a0-3f9d2e0836a9"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -18,13 +18,13 @@
|
||||
{"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}}
|
||||
{"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"83b27ad0-4286-478f-92ef-3bc82bf0589b"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}
|
||||
{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_kFKHaEXcTYEex0iDZw0C2432"},"content":[{"type":"tool-result","toolCallId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false}],"role":"user","id":"d6e29f9e-8177-4f12-b1b6-2eda4c8f5c31"}},"sourceEventSeqs":[134],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}
|
||||
{"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}
|
||||
{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-a.md</path>\n<type>file</type>\n<content>\n1: # alpha nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-b.md</path>\n<type>file</type>\n<content>\n1: # beta nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212"},"content":[{"type":"tool-result","toolCallId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-a.md</path>\n<type>file</type>\n<content>\n1: # alpha nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"efca7baf-cbb8-462f-ad75-580357b58676"}},"sourceEventSeqs":[136],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224"},"content":[{"type":"tool-result","toolCallId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-b.md</path>\n<type>file</type>\n<content>\n1: # beta nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"e1c70190-9e68-429d-963d-8a30ab779ddd"}},"sourceEventSeqs":[137],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -35,11 +35,11 @@
|
||||
{"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}}
|
||||
{"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0115844e-fac0-4985-9ba7-dcf1dde63b83"}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1d7a8563-4ae6-49f3-9a65-41f3df300a75"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}}
|
||||
{"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}}
|
||||
@@ -49,6 +49,6 @@
|
||||
{"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}}
|
||||
{"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}}
|
||||
{"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"70b38db9-38a2-4f9f-8094-1bd799f5f270"}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -1 +1,53 @@
|
||||
- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}"
|
||||
- toolbar "Trajectory toolbar":
|
||||
- button "Use actual duration": Duration
|
||||
- button "Collapse turns": Turns
|
||||
- button "Collapse calls": Calls
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
- cell "SYSTEM"
|
||||
- cell "Initial System Prompt"
|
||||
- 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."':
|
||||
- cell "Turn 1 USER"
|
||||
- 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."'
|
||||
- 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."':
|
||||
- 'cell "Request #1 ASSISTANT"':
|
||||
- 'button "Request #1"'
|
||||
- text: ASSISTANT
|
||||
- 'cell "The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."'
|
||||
- 'row "TOOL, bash {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]':
|
||||
- cell "TOOL"
|
||||
- 'cell "bash{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} → NAVIGATION_OK"'
|
||||
- 'row "TOOL, read {\"file_path\": \"nav-a.md\"}"':
|
||||
- cell "TOOL"
|
||||
- 'cell "read{\"file_path\": \"nav-a.md\"} → <path>{{cwd}}/nav-a.md</path> <type>file</type> <content> 1: # alpha nav (End of file - total 1 lines) </content>"'
|
||||
- 'row "TOOL, read {\"file_path\": \"nav-b.md\"}"':
|
||||
- cell "TOOL"
|
||||
- 'cell "read{\"file_path\": \"nav-b.md\"} → <path>{{cwd}}/nav-b.md</path> <type>file</type> <content> 1: # beta nav (End of file - total 1 lines) </content>"'
|
||||
- row "Request 2, ASSISTANT, FIRST_DONE":
|
||||
- 'cell "Request #2 ASSISTANT"':
|
||||
- 'button "Request #2"'
|
||||
- text: ASSISTANT
|
||||
- cell "FIRST_DONE"
|
||||
- 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."':
|
||||
- cell "Turn 2 USER"
|
||||
- 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."'
|
||||
- row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
|
||||
- 'cell "Request #3 ASSISTANT"':
|
||||
- 'button "Request #3"'
|
||||
- text: ASSISTANT
|
||||
- cell "Navigation Summary alpha nav beta nav echo WATERFALL"
|
||||
- complementary "Event details":
|
||||
- separator "Resize event details"
|
||||
- text: TOOL Turn 1 · Step 1
|
||||
- button "Close details"
|
||||
- tablist "Event details":
|
||||
- tab "Summary"
|
||||
- tab "Payload"
|
||||
- tab "Result" [selected]
|
||||
- tab "Schema"
|
||||
- tab "Timing"
|
||||
- tabpanel "Result": NAVIGATION_OK
|
||||
@@ -1 +0,0 @@
|
||||
- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2
|
||||
@@ -4,18 +4,23 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."
|
||||
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
@@ -25,7 +30,11 @@
|
||||
- img
|
||||
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
|
||||
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -26,6 +26,6 @@
|
||||
{"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}}
|
||||
{"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b5f572cb-f2a7-4fa0-8097-a5551700a920"}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -15,11 +15,11 @@
|
||||
{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
|
||||
{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
|
||||
{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"<path>{{cwd}}/workspace/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"<path>{{cwd}}/workspace/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"<path>{{cwd}}/workspace/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"<path>{{cwd}}/workspace/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -31,6 +31,6 @@
|
||||
{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
|
||||
{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"eb77fa3d-5d60-4028-a592-e1f07a288f35"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,10 +12,10 @@
|
||||
{"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ac0cfafd-795f-4bc3-9dc9-a8df33538a38"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
|
||||
{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
|
||||
{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sAvjivLShvnWVk0sPQPV7661"},"content":[{"type":"tool-result","toolCallId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false}],"role":"user","id":"9c2175f8-3cd4-4d6c-9320-498d25f83342"}},"sourceEventSeqs":[89],"surfaceOp":"append"}
|
||||
{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"message":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"291e02ad-cf8a-459f-a388-f81ed334e629"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -26,6 +26,6 @@
|
||||
{"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}}
|
||||
{"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}}
|
||||
{"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b49cca3a-fa40-4f45-91c9-e7c3f15fa233"}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,6 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
@@ -12,6 +11,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md
|
||||
compaction.md: 3ba5edd96c509e064ac7033b175b7ddd3c972452
|
||||
compaction.zh.md: d082b0d0545802500278e96ca41a273bce275f53
|
||||
compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a
|
||||
compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b
|
||||
@@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de
|
||||
| Event | Payload | Role |
|
||||
|---|---|---|
|
||||
| `compact/start` | `{ turn }` | acquires the log-recorded lock |
|
||||
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) |
|
||||
| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) |
|
||||
| `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) |
|
||||
|
||||
The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished.
|
||||
@@ -22,7 +22,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b
|
||||
|
||||
## `CompactionResult`
|
||||
|
||||
What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count.
|
||||
What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, shadowed range and seqs, and estimated token count.
|
||||
|
||||
```ts type-equiv
|
||||
/** Result of a successful compaction operation. */
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 事件 | 载荷 | 作用 |
|
||||
|---|---|---|
|
||||
| `compact/start` | `{ turn }` | 获取日志记录的锁 |
|
||||
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) |
|
||||
| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) |
|
||||
| `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) |
|
||||
|
||||
锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
## `CompactionResult`
|
||||
|
||||
成功压缩向调用方返回:记账事件 seq、原始摘要、被遮蔽的范围与 seq,以及估算 token 数。
|
||||
成功压缩向调用方返回:记账事件 seq、安全摘要投影、被遮蔽的范围与 seq,以及估算 token 数。
|
||||
|
||||
```ts type-equiv
|
||||
/** Result of a successful compaction operation. */
|
||||
|
||||
@@ -265,7 +265,6 @@ flowchart TD
|
||||
pkg_client_ui_primitives --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_slots --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
pkg_code_runtime --> pkg_invariants
|
||||
@@ -305,6 +304,8 @@ flowchart TD
|
||||
pkg_client_ui_slash --> pkg_client_runtime
|
||||
pkg_client_ui_slash --> pkg_client_ui_slots
|
||||
pkg_client_ui_slash --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
pkg_client_ui_workspace --> pkg_client_ui_primitives
|
||||
pkg_client_ui_workspace --> pkg_client_ui_slots
|
||||
@@ -989,7 +990,6 @@ flowchart TD
|
||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -1008,6 +1008,7 @@ flowchart TD
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
|
||||
@@ -213,7 +213,7 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in
|
||||
'compact/end': { turn: number; error?: string }
|
||||
```
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact/src/types.ts)
|
||||
Source: [`packages/compact/compact/src/types.ts:44`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
#### `compact/start` — log-only
|
||||
|
||||
@@ -235,6 +235,8 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact
|
||||
*/
|
||||
'compact/summary': {
|
||||
summary: ContentBlock[]
|
||||
/** Complete provider output before the backend's safe summary projection. */
|
||||
rawOutput?: ContentBlock[]
|
||||
shadowedRange: { start: number; end: number }
|
||||
shadowedSeqs: number[]
|
||||
shadowedTokenCount: number
|
||||
@@ -249,10 +251,12 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact
|
||||
model: string
|
||||
/** The generation cap the summarize call sent, when one applied. */
|
||||
maxTokens?: number
|
||||
/** Provider-reported token usage for the summarization request, when emitted. */
|
||||
usage?: TokenUsage
|
||||
}
|
||||
```
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220
|
||||
README.zh.md: 5531cd4724df2c63a9a5c6d1923b52c1f578db10
|
||||
README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
|
||||
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录末尾的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from '../sessions/history.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Observable state of one independently loaded session history ledger. */
|
||||
export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
/** Read-only history source addressed by session id. */
|
||||
export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
export interface ISessionHistory {
|
||||
/**
|
||||
* Resolve the identity-stable source for a session.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns The source owned outside Session and SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
@@ -12,6 +13,7 @@ import type { UseProjection } from './sessions/projection-store.ts'
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { SessionHistoryService } from './session-history/service.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
// materialization/projection implementation; no test-side mirror to drift).
|
||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
||||
@@ -21,6 +23,9 @@ export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
@@ -38,10 +43,19 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
@@ -120,6 +134,8 @@ declare module 'cordis' {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
/** Read-only history sources isolated from Chat sessions and workspace state. */
|
||||
sessionHistory: import('./contract/session-history.ts').ISessionHistory
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
@@ -135,30 +151,55 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onMuxEnvelope: (envelope) => {
|
||||
sessions.handleMuxEnvelope(envelope)
|
||||
try {
|
||||
sessionHistory.handleMuxEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
try {
|
||||
sessionHistory.handleHostEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
try {
|
||||
sessionHistory.handleConnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') sessions.handleDisconnected()
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
time: number
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
// History projection owns its node mapping so Chat's live adapter remains free
|
||||
// of inspection metadata and lifecycle coupling.
|
||||
/* jscpd:ignore-start */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId,
|
||||
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
> {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
// The independent replay emits the same public running-call shape as
|
||||
// Chat without reading or mutating Session's live index.
|
||||
/* jscpd:ignore-start */
|
||||
codeDispatches.set(data.parentCallId, [...siblings, {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
}])
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
// History independently reproduces the public settled-call shape instead
|
||||
// of consuming Session's live code-dispatch projection.
|
||||
/* jscpd:ignore-start */
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
}
|
||||
codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (partial === null || partial.turn !== turn || partial.step !== step) {
|
||||
partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
partial.push(chunk)
|
||||
break
|
||||
}
|
||||
case 'assistant/message':
|
||||
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
|
||||
break
|
||||
case 'tool/call':
|
||||
// History reconstructs its own in-flight index; this intentionally
|
||||
// mirrors the published Chat node shape, not Chat's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId),
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
case 'tool/result':
|
||||
openCalls.delete(String(event.data.message.source.callId))
|
||||
break
|
||||
case 'turn/end': {
|
||||
if (partial !== null && partial.turn === event.data.turn) {
|
||||
const { blocks } = partial.toPartial()
|
||||
const visible = blocks.some(block =>
|
||||
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
|
||||
if (visible) {
|
||||
interruptedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: partial.turn, step: partial.step, blocks, interrupted: true,
|
||||
})
|
||||
}
|
||||
partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
openCalls.delete(callId)
|
||||
// Interrupted terminal nodes are reconstructed independently so a
|
||||
// Trajectory replay cannot observe Session's frozen-node lifecycle.
|
||||
/* jscpd:ignore-start */
|
||||
interruptedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
|
||||
time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one immutable history ledger without reading or mutating Chat state.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Event order, context lineage, and transient tail state.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
const assistantTimings = new Map<number, AssistantTiming>()
|
||||
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
let activeRequestConfig: AssistantRequestConfig | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let contextGeneration = 0
|
||||
|
||||
for (const [index, event] of events.entries()) {
|
||||
const view = entries[index]?.view
|
||||
if (event.type === 'tool/call') {
|
||||
callIndex.set(String(event.data.callId), {
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
} else if (event.type === 'tool/result' && view?.for === 'result') {
|
||||
resultViews.set(event.seq, view.view)
|
||||
}
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeCache = new Map<number, ConversationNode>()
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
callIndex,
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
const eventNodes = events.flatMap((event) => {
|
||||
const node = materialize(event.seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
|
||||
let contexts: readonly ConversationContext[]
|
||||
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
const prompt = promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history surface fold failed, using event order:', error)
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ISessionHistory, SessionHistoryFace,
|
||||
} from '../contract/session-history.ts'
|
||||
import { SessionHistorySource } from './source.ts'
|
||||
|
||||
/** Root registry and frame router for independent inspection histories. */
|
||||
export class SessionHistoryService implements ISessionHistory {
|
||||
private readonly sources = new Map<SessionId, SessionHistorySource>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root context.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient) {
|
||||
ctx.reflect.provide('sessionHistory', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one identity-stable history source.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns Source independent from SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace {
|
||||
let source = this.sources.get(sessionId)
|
||||
if (source === undefined) {
|
||||
source = new SessionHistorySource(sessionId, this.api)
|
||||
this.sources.set(sessionId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Route history-relevant mux frames only to an existing source.
|
||||
* @param envelope - Validated mux envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return
|
||||
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a removed session's independent history source.
|
||||
* @param envelope - Validated host envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type !== 'host/session-removed') return
|
||||
this.sources.get(frame.sessionId)?.dispose()
|
||||
this.sources.delete(frame.sessionId)
|
||||
}
|
||||
|
||||
/** Invalidate requests from the dead connection generation. */
|
||||
handleDisconnected(): void {
|
||||
for (const source of this.sources.values()) source.handleDisconnected()
|
||||
}
|
||||
|
||||
/** Rebuild every previously activated source from the new generation. */
|
||||
handleConnected(): void {
|
||||
for (const source of this.sources.values()) source.resync()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
private error: RpcError | null = null
|
||||
private generation = 0
|
||||
private persistentConsumer = false
|
||||
private readonly consumerSignals = new Set<AbortSignal>()
|
||||
private openPromise: Promise<void> | null = null
|
||||
private olderPromise: Promise<void> | null = null
|
||||
private stitching = false
|
||||
private liveBuffer: HistoryEntry[] = []
|
||||
private subscribedLastSeq: number | null = null
|
||||
private inspectionCache: {
|
||||
entries: readonly HistoryEntry[]
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ledger changes.
|
||||
* @param listener - Change callback.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached ledger snapshot.
|
||||
* @returns Stable snapshot until the source changes.
|
||||
*/
|
||||
getSnapshot(): SessionHistorySnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a relevant mux frame without involving the Chat session.
|
||||
* @param frame - Session-addressed frame.
|
||||
*/
|
||||
handleMuxFrame(frame: MuxFrame): void {
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return
|
||||
}
|
||||
if (frame.type !== 'session/event') return
|
||||
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
|
||||
}
|
||||
|
||||
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
|
||||
handleDisconnected(): void {
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
if (this.state !== 'cold') {
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an activated ledger from the new connection generation. */
|
||||
resync(): void {
|
||||
if (!this.hasConsumer()) return
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
void this.loadForConsumers()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
dispose(): void {
|
||||
this.persistentConsumer = false
|
||||
this.consumerSignals.clear()
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
}
|
||||
|
||||
private open(): Promise<void> {
|
||||
if (this.state === 'ready') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const generation = this.generation
|
||||
const operation = this.doOpen(generation)
|
||||
const settled = operation.finally(() => {
|
||||
if (this.openPromise === settled) this.openPromise = null
|
||||
})
|
||||
this.openPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private trackConsumer(signal: AbortSignal | undefined): void {
|
||||
if (signal === undefined) {
|
||||
this.persistentConsumer = true
|
||||
return
|
||||
}
|
||||
if (this.consumerSignals.has(signal)) return
|
||||
this.consumerSignals.add(signal)
|
||||
signal.addEventListener('abort', () => {
|
||||
this.consumerSignals.delete(signal)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
private hasConsumer(): boolean {
|
||||
return this.persistentConsumer || this.consumerSignals.size > 0
|
||||
}
|
||||
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
return
|
||||
}
|
||||
this.installTail(result.value.events, result.value.hasMore, true)
|
||||
const tailSeq = this.tailSeq()
|
||||
if (
|
||||
this.subscribedLastSeq !== null
|
||||
&& tailSeq !== null
|
||||
&& this.subscribedLastSeq > tailSeq
|
||||
) {
|
||||
result = (await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})).result
|
||||
if (generation !== this.generation) return
|
||||
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
|
||||
}
|
||||
this.state = 'ready'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the error branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.generation) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
beforeSeq: this.baseSeq,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older.at(-1)
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
console.error(
|
||||
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
|
||||
)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history paging failed:', error)
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.olderPromise !== settled) return
|
||||
this.olderPromise = null
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.olderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private installTail(
|
||||
tail: readonly HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
replace: boolean,
|
||||
): void {
|
||||
if (replace) {
|
||||
this.entries = [...tail]
|
||||
this.hasMore = hasMore
|
||||
} else {
|
||||
const firstSeq = tail[0]?.event.seq
|
||||
const prefix = firstSeq === undefined
|
||||
? this.entries
|
||||
: this.entries.filter(entry => entry.event.seq < firstSeq)
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private acceptLive(entry: HistoryEntry): void {
|
||||
if (this.state === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push(entry)
|
||||
return
|
||||
}
|
||||
if (this.state !== 'ready') return
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push(entry)
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
}
|
||||
|
||||
private async repairGap(): Promise<void> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.generation
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (result.ok && generation === this.generation && this.state === 'ready') {
|
||||
this.installTail(result.value.events, result.value.hasMore, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.generation) this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
private tailSeq(): number | null {
|
||||
return this.entries.at(-1)?.event.seq ?? null
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionHistorySnapshot {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
inspection: this.inspectionCache.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
|
||||
/** One immutable model-context generation reconstructed from surface replacements. */
|
||||
export interface ConversationContext {
|
||||
/** Zero-based generation within the session; stable across later appends. */
|
||||
id: number
|
||||
/** Previous generation in this session; absent for the initial context. */
|
||||
parentId?: number
|
||||
/** Why this generation exists; absent for the initial context. */
|
||||
origin?: ConversationContextOriginKind
|
||||
/** Event seq of the replacement that created this generation. */
|
||||
originSeq?: number
|
||||
/** Unix epoch ms of the replacement that created this generation. */
|
||||
createdAt?: number
|
||||
/** Latest request header observed in this generation, inherited until a later header replaces it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
|
||||
nodes: readonly ConversationNode[]
|
||||
}
|
||||
@@ -10,9 +10,26 @@ import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
export type { TodoItem }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
export interface AssistantRequestConfig {
|
||||
provider: string
|
||||
model: string
|
||||
purpose?: string
|
||||
thinking?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stop?: readonly string[]
|
||||
}
|
||||
|
||||
/** Stable provider/model identity reported for one completed request. */
|
||||
export interface AssistantProvenanceView {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
@@ -54,6 +71,16 @@ export interface UserMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Recorded boundaries used to derive assistant latency and throughput. */
|
||||
export interface AssistantTiming {
|
||||
/** Matching step/start timestamp, or null when it is outside the current event window. */
|
||||
stepStartTime: number | null
|
||||
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
|
||||
firstTokenTime: number | null
|
||||
/** Final assistant/message timestamp. */
|
||||
completedTime: number
|
||||
}
|
||||
|
||||
/** A finalized (or interruption-frozen) assistant message. */
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
@@ -64,6 +91,10 @@ export interface AssistantMessageNode {
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
usage?: unknown
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
/** Timing derived from the recorded step/chunk/message event sequence. */
|
||||
timing?: AssistantTiming
|
||||
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
|
||||
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
|
||||
interrupted?: true
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
@@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
@@ -137,7 +144,7 @@ export class FoldAdapter {
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = false
|
||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
@@ -160,6 +167,7 @@ export class FoldAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
* the entries and replays event order and request lifecycle state.
|
||||
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
|
||||
* @returns Lazy, memoized inspection fields for that exact window.
|
||||
*/
|
||||
export function createHistoryInspection(
|
||||
loadEntries: () => readonly HistoryEntry[],
|
||||
): SessionHistoryInspection {
|
||||
let entries: readonly HistoryEntry[] | undefined
|
||||
let conversation: ReturnType<typeof projectConversationHistory> | undefined
|
||||
let requests: ReturnType<typeof inspectRequests> | undefined
|
||||
const historyEntries = () => entries ??= loadEntries()
|
||||
const conversationProjection = () =>
|
||||
conversation ??= projectConversationHistory(historyEntries())
|
||||
const requestProjection = () =>
|
||||
requests ??= inspectRequests(historyEntries())
|
||||
return {
|
||||
get eventNodes() {
|
||||
return conversationProjection().eventNodes
|
||||
},
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get interruptedNodes() {
|
||||
return conversationProjection().interruptedNodes
|
||||
},
|
||||
get partial() {
|
||||
return conversationProjection().partial
|
||||
},
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
get callSchemas() {
|
||||
return requestProjection().callSchemas
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Request-centric inspection read model. Ordinary generation and compaction
|
||||
// calls share one chronological projection; presentation-specific grouping
|
||||
// remains in the trajectory consumer.
|
||||
|
||||
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
/** Complete model-visible request header in force for an ordinary generation. */
|
||||
export interface ConversationPromptSnapshot {
|
||||
/** Provider/model and sampling configuration from the effective request header. */
|
||||
config: AssistantRequestConfig
|
||||
/** Rendered system prompt text; empty when the request had no system prompt. */
|
||||
system: string
|
||||
/** Complete tool catalog sent with the request, including tools that were never called. */
|
||||
tools: readonly ToolSchema[]
|
||||
}
|
||||
|
||||
/** System/tool change introduced while preparing one ordinary request. */
|
||||
export interface RequestPromptChange {
|
||||
/** Sequence of the request/header event that introduced this state. */
|
||||
seq: number
|
||||
/** Unix epoch ms from the request/header event. */
|
||||
time: number
|
||||
/** How the model-visible prompt differs from the previous recorded state. */
|
||||
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
|
||||
/** State immediately before this change; absent for the initial header. */
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
export function inspectRequests(
|
||||
entries: readonly HistoryEntry[],
|
||||
): RequestInspectionSnapshot {
|
||||
const events = entries.map(entry => entry.event)
|
||||
return {
|
||||
requests: deriveRequests(events),
|
||||
callSchemas: deriveCallSchemas(events),
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryEvent {
|
||||
type: 'llm/retry'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: { message: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
type: 'compact/summary'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
summary: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
usage?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
|
||||
const previous = current as TokenUsage | undefined
|
||||
return {
|
||||
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
|
||||
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
|
||||
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheReadTokens:
|
||||
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
|
||||
}),
|
||||
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheWriteTokens:
|
||||
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
|
||||
}),
|
||||
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
reasoningTokens:
|
||||
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCallSchemas(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlyMap<string, ToolSchema> {
|
||||
let active = new Map<string, ToolSchema>()
|
||||
const calls = new Map<string, ToolSchema>()
|
||||
const capture = (callId: string, name: string): void => {
|
||||
if (calls.has(callId)) return
|
||||
const schema = active.get(name)
|
||||
if (schema !== undefined) calls.set(callId, schema)
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
const tools: unknown = event.data.header.tools
|
||||
active = new Map(
|
||||
Array.isArray(tools)
|
||||
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
|
||||
: [],
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
const type = event.type as string
|
||||
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as { subCallId: string; name: string }
|
||||
capture(data.subCallId, data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project ordinary and compaction provider calls into one chronological request stream. */
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activePrompt === undefined
|
||||
? {}
|
||||
: { prompt: activePrompt, requestConfig: activePrompt.config }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
const tools: unknown = sourceEvent.data.header.tools
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: sourceEvent.data.header.config,
|
||||
system: sourceEvent.data.header.system ?? '',
|
||||
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'assistant/chunk'
|
||||
&& sourceEvent.data.chunk.type === 'usage'
|
||||
) {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/end') {
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
step: 0,
|
||||
startedAt: event.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
provenance: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
purpose: 'compaction',
|
||||
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
|
||||
},
|
||||
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'user/message'
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
return requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
@@ -27,6 +28,7 @@ describe('FoldAdapter', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
|
||||
const first = adapter.nodes()
|
||||
expect(adapter.nodes()).toBe(first)
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second.nodes).toHaveLength(3)
|
||||
@@ -35,6 +37,52 @@ describe('FoldAdapter', () => {
|
||||
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes all six node variants with field mapping', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const events = [
|
||||
@@ -114,6 +162,68 @@ describe('FoldAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
at(10, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 3 },
|
||||
sourceEventSeqs: [1, 3],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ev.user(11, 'newer message'),
|
||||
], 10)
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a live replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([ev.user(10, 'window head')], 10)
|
||||
adapter.append(at(11, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'live summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
@@ -130,6 +240,44 @@ describe('FoldAdapter', () => {
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes the in-window call index for runningCalls material', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
|
||||
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
|
||||
const retryUsage = {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
}
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: chunkUsage },
|
||||
}),
|
||||
at(2, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 100,
|
||||
failure: { message: 'rate limited' },
|
||||
}),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: retryUsage },
|
||||
}),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
status: 'complete',
|
||||
usage: {
|
||||
inputTokens: 26,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve(undefined)
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -727,6 +727,7 @@ describe('resync', () => {
|
||||
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
@@ -840,20 +841,23 @@ describe('reference stability (the memo contract)', () => {
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
|
||||
feed(ev.stepStart(7, 1))
|
||||
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
|
||||
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
|
||||
const before = session.getSnapshot()
|
||||
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '与工具无关的流式'))
|
||||
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '与工具无关的流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
expect(resolved.runningCalls).not.toBe(after.runningCalls)
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
feed(ev.assistant(12, 1, '完成'))
|
||||
expect(session.getSnapshot()).not.toBe(resolved)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
|
||||
* tag at factory execution (the loader removes plugin-owned tags on unload).
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
@@ -127,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f
|
||||
README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f
|
||||
README.md: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
|
||||
README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910
|
||||
@@ -20,7 +20,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
输入栏声明两个会话作用域的单实例 seat:`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 会将文本框的 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取由 host 折叠的值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import type {
|
||||
ComposerBarOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
|
||||
/** Machine-backed wiring over a sink spy. */
|
||||
function fakeWiring() {
|
||||
@@ -99,7 +101,14 @@ function mount(
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
/>
|
||||
@@ -207,6 +216,13 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps pending takeover interaction accessible outside the Chat view', () => {
|
||||
const b = mount(conversationSnapshot({ pending: [{} as never] }))
|
||||
act(() => { b.chat.actions.setView('trajectory') })
|
||||
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
|
||||
README.zh.md: 567881e8ca7d5e8017f82884cd638f08b13fc7e7
|
||||
README.md: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
|
||||
README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, and TerminalBlock. Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器,以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任的 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
## 终端输出
|
||||
|
||||
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
||||
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)",
|
||||
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -23,6 +23,9 @@
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"anser": "^2.3.5",
|
||||
"clsx": "^2.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
.root {
|
||||
--json-tree-property: #881391;
|
||||
--json-tree-string: #c41a16;
|
||||
--json-tree-number: #1c00cf;
|
||||
--json-tree-keyword: #1c00cf;
|
||||
--json-tree-punctuation: #202124;
|
||||
--json-tree-icon: #5f6368;
|
||||
--json-tree-hover: rgb(60 64 67 / 4%);
|
||||
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: 12px/16px var(--ds-font-family-code);
|
||||
overscroll-behavior-x: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .root {
|
||||
--json-tree-property: #5db0d7;
|
||||
--json-tree-string: #f28b82;
|
||||
--json-tree-number: #99c8ff;
|
||||
--json-tree-keyword: #99c8ff;
|
||||
--json-tree-punctuation: #e8eaed;
|
||||
--json-tree-icon: #9aa0a6;
|
||||
--json-tree-hover: rgb(232 234 237 / 5%);
|
||||
}
|
||||
|
||||
.container {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 6px 8px 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.expandedTopLevel {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 6px 8px 8px 14px;
|
||||
}
|
||||
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
|
||||
background: var(--json-tree-hover);
|
||||
}
|
||||
|
||||
.expandedTopLevelContainer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row.topLevelBracket {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
min-height: 16px;
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
|
||||
.row:not(.topLevelBracket)[data-json-copy-active]::after,
|
||||
.row:has(> .expander:focus-visible)::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 16px;
|
||||
background: var(--json-tree-hover);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row > span:not(.expander) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 3px;
|
||||
color: var(--json-tree-property);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clickableLabel {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stringValue {
|
||||
color: var(--json-tree-string);
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
color: var(--json-tree-number);
|
||||
}
|
||||
|
||||
.keywordValue {
|
||||
color: var(--json-tree-keyword);
|
||||
}
|
||||
|
||||
.otherValue {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.punctuation {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.preview {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewProperty {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewEllipsis {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.copyAnchor {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.copyButton:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.copyButton[data-state='failed'] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.expander {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
color: var(--json-tree-icon);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.expander::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid currentColor;
|
||||
content: '';
|
||||
transform: scale(0.75);
|
||||
transform-origin: 33.333% center;
|
||||
}
|
||||
|
||||
.collapseIcon::before {
|
||||
transform: rotate(90deg) scale(0.75);
|
||||
}
|
||||
|
||||
.expander:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.expander:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.collapsedContent {
|
||||
margin: 0 1px;
|
||||
color: var(--json-tree-punctuation);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsedContent::after {
|
||||
content: '…';
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
UIEvent as ReactUIEvent,
|
||||
} from 'react'
|
||||
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
|
||||
import { Menu } from './Menu.tsx'
|
||||
import type { MenuEntry } from './Menu.tsx'
|
||||
import css from './JsonTree.module.css'
|
||||
|
||||
const OBJECT_PREVIEW_LIMIT = 4
|
||||
const ARRAY_PREVIEW_LIMIT = 5
|
||||
const PREVIEW_DEPTH_LIMIT = 2
|
||||
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'value', label: 'Copy value' },
|
||||
{ id: 'json', label: 'Copy JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'prettyJson', label: 'Copy pretty JSON' },
|
||||
{ id: 'json', label: 'Copy compact JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
type JsonPath = readonly (number | string)[]
|
||||
|
||||
interface RowTarget {
|
||||
path: JsonPath
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface CopyTarget extends RowTarget {
|
||||
left: number
|
||||
side: 'bottom' | 'top'
|
||||
top: number
|
||||
}
|
||||
|
||||
function isExpandableValue(value: unknown): value is object | unknown[] {
|
||||
return typeof value === 'object' && value !== null && !(value instanceof Date)
|
||||
}
|
||||
|
||||
function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => [String(index), item] as const)
|
||||
}
|
||||
return Object.keys(value).map(key => [
|
||||
key,
|
||||
(value as Record<string, unknown>)[key],
|
||||
] as const)
|
||||
}
|
||||
|
||||
function bracketOf(value: object | unknown[]): readonly [string, string] {
|
||||
return Array.isArray(value) ? ['[', ']'] : ['{', '}']
|
||||
}
|
||||
|
||||
function previewPrimitive(value: unknown): ReactNode {
|
||||
if (value === null) return <span className={css.keywordValue}>null</span>
|
||||
if (typeof value === 'string') {
|
||||
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className={css.numberValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={css.keywordValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return <span className={css.otherValue}>{value.toString()}</span>
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return <span className={css.otherValue}>undefined</span>
|
||||
}
|
||||
if (typeof value === 'symbol') {
|
||||
return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return <span className={css.otherValue}>{value.name || 'Function'}</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function previewValue(value: unknown, depth: number): ReactNode {
|
||||
if (!isExpandableValue(value)) return previewPrimitive(value)
|
||||
|
||||
const array = Array.isArray(value)
|
||||
const entries = entriesOf(value)
|
||||
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
|
||||
const visible = entries.slice(0, limit)
|
||||
const [open, close] = bracketOf(value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={css.punctuation}>{open}</span>
|
||||
{depth >= PREVIEW_DEPTH_LIMIT
|
||||
? <span className={css.previewEllipsis}>…</span>
|
||||
: visible.map(([key, item], index) => (
|
||||
<span key={key}>
|
||||
{index > 0 && <span className={css.punctuation}>, </span>}
|
||||
{!array && (
|
||||
<>
|
||||
<span className={css.previewProperty}>{key}</span>
|
||||
<span className={css.punctuation}>: </span>
|
||||
</>
|
||||
)}
|
||||
{previewValue(item, depth + 1)}
|
||||
</span>
|
||||
))}
|
||||
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
|
||||
<span className={css.previewEllipsis}>, …</span>
|
||||
)}
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function primitiveValue(value: unknown): ReactNode {
|
||||
if (value === null) return <span className={css.keywordValue}>null</span>
|
||||
if (typeof value === 'string') {
|
||||
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={css.keywordValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className={css.numberValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return <span className={css.numberValue}>{`${value.toString()}n`}</span>
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return <span className={css.otherValue}>{value.toISOString()}</span>
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return <span className={css.otherValue}>function() {'{ }'}</span>
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return <span className={css.otherValue}>undefined</span>
|
||||
}
|
||||
return <span className={css.otherValue}>{(value as symbol).toString()}</span>
|
||||
}
|
||||
|
||||
function fieldText(field: string): string {
|
||||
return field === '' ? '""' : field
|
||||
}
|
||||
|
||||
function pathId(path: JsonPath): string {
|
||||
return path.map(part => (
|
||||
typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
|
||||
)).join('/')
|
||||
}
|
||||
|
||||
function claimFocus(button: HTMLElement): void {
|
||||
button.focus()
|
||||
}
|
||||
|
||||
function moveFocus(button: HTMLElement, direction: -1 | 1): void {
|
||||
const tree = button.closest<HTMLElement>('[role="tree"]')
|
||||
/* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
|
||||
if (tree === null) return
|
||||
const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
|
||||
const current = expanders.indexOf(button)
|
||||
/* v8 ignore next -- the current expander is a member of the queried non-empty set. */
|
||||
if (current < 0 || expanders.length === 0) return
|
||||
const next = (current + direction + expanders.length) % expanders.length
|
||||
const nextExpander = expanders[next]
|
||||
/* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
|
||||
if (nextExpander !== undefined) claimFocus(nextExpander)
|
||||
}
|
||||
|
||||
function NodeField({
|
||||
field,
|
||||
expandable,
|
||||
onToggle,
|
||||
}: {
|
||||
field: string | undefined
|
||||
expandable: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (field === undefined) return null
|
||||
return (
|
||||
<span
|
||||
className={clsx(css.label, expandable && css.clickableLabel)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
>
|
||||
{fieldText(field)}:
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface JsonTreeNodeProps {
|
||||
field?: string
|
||||
initialExpanded: boolean
|
||||
lastElement: boolean
|
||||
onClaimTabStop: (id: string) => void
|
||||
onRowHover: (row: HTMLElement, target: RowTarget) => void
|
||||
path: JsonPath
|
||||
tabStopId: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function JsonTreeNode({
|
||||
field,
|
||||
initialExpanded,
|
||||
lastElement,
|
||||
onClaimTabStop,
|
||||
onRowHover,
|
||||
path,
|
||||
tabStopId,
|
||||
value,
|
||||
}: JsonTreeNodeProps) {
|
||||
const contentsId = useId()
|
||||
const expanderRef = useRef<HTMLSpanElement>(null)
|
||||
const [expanded, setExpanded] = useState(initialExpanded)
|
||||
const nodeId = pathId(path)
|
||||
const container = isExpandableValue(value)
|
||||
const entries = container ? entriesOf(value) : []
|
||||
const expandable = entries.length > 0
|
||||
|
||||
const toggle = () => {
|
||||
setExpanded(current => !current)
|
||||
claimFocus(expanderRef.current as HTMLSpanElement)
|
||||
}
|
||||
|
||||
const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
setExpanded(event.key === 'ArrowRight')
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
const row = (children: ReactNode, ariaExpanded?: boolean) => (
|
||||
<div
|
||||
className={css.row}
|
||||
role="treeitem"
|
||||
aria-expanded={ariaExpanded}
|
||||
onMouseOver={(event) => {
|
||||
event.stopPropagation()
|
||||
onRowHover(event.currentTarget, { path, value })
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!container) {
|
||||
return row((
|
||||
<>
|
||||
<NodeField field={field} expandable={false} onToggle={toggle} />
|
||||
{primitiveValue(value)}
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
|
||||
const [open, close] = bracketOf(value)
|
||||
if (!expandable) {
|
||||
return row((
|
||||
<>
|
||||
<NodeField field={field} expandable={false} onToggle={toggle} />
|
||||
<span className={css.punctuation}>{open}</span>
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
|
||||
return row((
|
||||
<>
|
||||
<span
|
||||
ref={expanderRef}
|
||||
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
|
||||
data-json-expander
|
||||
role="button"
|
||||
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? contentsId : undefined}
|
||||
tabIndex={tabStopId === nodeId ? 0 : -1}
|
||||
onFocus={() => { onClaimTabStop(nodeId) }}
|
||||
onClick={toggle}
|
||||
onKeyDown={onExpanderKeyDown}
|
||||
/>
|
||||
<NodeField field={field} expandable onToggle={toggle} />
|
||||
<span className={css.preview}>{previewValue(value, 0)}</span>
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
{expanded && (
|
||||
<ul id={contentsId} role="group" className={css.children}>
|
||||
{entries.map(([key, item], index) => (
|
||||
<JsonTreeNode
|
||||
key={key}
|
||||
field={key}
|
||||
value={item}
|
||||
path={[...path, Array.isArray(value) ? index : key]}
|
||||
lastElement={index === entries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={onClaimTabStop}
|
||||
onRowHover={onRowHover}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
), expanded)
|
||||
}
|
||||
|
||||
function formattedPath(path: JsonPath): string {
|
||||
return path.reduce<string>((result, part) => {
|
||||
if (typeof part === 'number') return `${result}[${String(part)}]`
|
||||
return /^[A-Za-z_$][\w$]*$/.test(part)
|
||||
? `${result}.${part}`
|
||||
: `${result}[${JSON.stringify(part)}]`
|
||||
}, '$')
|
||||
}
|
||||
|
||||
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
|
||||
if (mode === 'path') return formattedPath(target.path)
|
||||
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
|
||||
if (mode === 'json') return JSON.stringify(target.value)
|
||||
if (typeof target.value === 'string') return target.value
|
||||
if (typeof target.value === 'undefined') return 'undefined'
|
||||
if (typeof target.value === 'bigint') return target.value.toString()
|
||||
if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
|
||||
if (typeof target.value === 'function') return target.value.name || 'Function'
|
||||
return JSON.stringify(target.value)
|
||||
}
|
||||
|
||||
/** Props for the read-only, token-themed JSON tree. */
|
||||
export interface JsonTreeProps {
|
||||
/** Parsed JSON object or array. */
|
||||
data: object | unknown[]
|
||||
/** Accessible label for the tree. */
|
||||
label?: string
|
||||
/** Optional positioning class owned by the caller. */
|
||||
className?: string | undefined
|
||||
/** Whether JSON rows expose copy actions. */
|
||||
copyable?: boolean
|
||||
/** Whether the top-level object or array is always expanded. */
|
||||
expandTopLevel?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
|
||||
* @param props - Parsed data, accessible label, and display options.
|
||||
* @returns A read-only JSON tree with an optionally fixed-open top level.
|
||||
*/
|
||||
export function JsonTree({
|
||||
data,
|
||||
label = 'JSON',
|
||||
className,
|
||||
copyable = true,
|
||||
expandTopLevel = true,
|
||||
}: JsonTreeProps) {
|
||||
const rootEntries = entriesOf(data)
|
||||
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
|
||||
isExpandableValue(value) && entriesOf(value).length > 0
|
||||
))
|
||||
const firstExpandableEntry = rootEntries[firstExpandableIndex]
|
||||
const initialTabStopId = expandTopLevel
|
||||
? firstExpandableEntry === undefined
|
||||
? null
|
||||
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
|
||||
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const activeRowRef = useRef<HTMLElement>()
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const copyMenuOpenRef = useRef(false)
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
|
||||
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
|
||||
|
||||
const setActiveRow = (row: HTMLElement | undefined) => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
activeRowRef.current = row
|
||||
row?.setAttribute('data-json-copy-active', '')
|
||||
}
|
||||
|
||||
const clearCopyTarget = () => {
|
||||
setActiveRow(undefined)
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}
|
||||
|
||||
const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
|
||||
const root = rootRef.current
|
||||
/* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
|
||||
if (root === null) throw new Error('JsonTree root is not mounted')
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
return {
|
||||
left: rootRect.left + root.clientWidth - 26,
|
||||
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
|
||||
top: rowRect.top,
|
||||
}
|
||||
}
|
||||
|
||||
const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
|
||||
const position = copyPosition(row)
|
||||
setCopyTarget({ ...target, ...position })
|
||||
}
|
||||
|
||||
const repositionCopyButton = (row: HTMLElement) => {
|
||||
const position = copyPosition(row)
|
||||
setCopyTarget((current) => {
|
||||
/* v8 ignore next -- an active row and its copy target are installed together. */
|
||||
if (current === undefined) return current
|
||||
return { ...current, ...position }
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
activeRowRef.current = undefined
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
setCopyMenuOpen(false)
|
||||
setTabStopId(initialTabStopId)
|
||||
}, [data, expandTopLevel, initialTabStopId])
|
||||
|
||||
useEffect(() => {
|
||||
const reposition = () => {
|
||||
const row = activeRowRef.current
|
||||
if (row !== undefined) repositionCopyButton(row)
|
||||
}
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.addEventListener('resize', reposition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.removeEventListener('resize', reposition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
if (activeRowRef.current === row) return
|
||||
setActiveRow(row)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
positionCopyButton(row, target)
|
||||
}
|
||||
|
||||
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
/* v8 ignore next -- browser mouse events delivered through React target an Element. */
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
|
||||
}
|
||||
|
||||
const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
|
||||
const row = activeRowRef.current
|
||||
if (row !== undefined) repositionCopyButton(row)
|
||||
}
|
||||
|
||||
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
|
||||
/* v8 ignore next -- copy controls only render while their target exists. */
|
||||
if (copyTarget === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(copyTarget, mode))
|
||||
setCopyState('copied')
|
||||
} catch {
|
||||
setCopyState('failed')
|
||||
}
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
|
||||
}
|
||||
|
||||
const [rootOpen, rootClose] = bracketOf(data)
|
||||
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
|
||||
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
|
||||
const copyTitle = copyState === 'copied'
|
||||
? 'Copied'
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onMouseOver={handleRootMouseOver}
|
||||
onMouseLeave={() => {
|
||||
if (!copyMenuOpenRef.current) clearCopyTarget()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{expandTopLevel
|
||||
? (
|
||||
<div className={css.expandedTopLevel}>
|
||||
<div
|
||||
className={clsx(css.row, css.topLevelBracket)}
|
||||
data-json-root-row
|
||||
onMouseOver={(event) => {
|
||||
event.stopPropagation()
|
||||
handleRowHover(event.currentTarget, { path: [], value: data })
|
||||
}}
|
||||
>
|
||||
<span className={css.punctuation}>{rootOpen}</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={label}
|
||||
className={clsx(css.container, css.expandedTopLevelContainer)}
|
||||
role="tree"
|
||||
>
|
||||
{rootEntries.map(([key, value], index) => (
|
||||
<JsonTreeNode
|
||||
key={key}
|
||||
field={key}
|
||||
value={value}
|
||||
path={[Array.isArray(data) ? index : key]}
|
||||
lastElement={index === rootEntries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={setTabStopId}
|
||||
onRowHover={handleRowHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={clsx(css.row, css.topLevelBracket)}>
|
||||
<span className={css.punctuation}>{rootClose}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div aria-label={label} className={css.container} role="tree">
|
||||
<JsonTreeNode
|
||||
value={data}
|
||||
path={[]}
|
||||
lastElement
|
||||
initialExpanded
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={setTabStopId}
|
||||
onRowHover={handleRowHover}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{copyTarget !== undefined && (
|
||||
<span
|
||||
className={css.copyAnchor}
|
||||
style={{ left: copyTarget.left, top: copyTarget.top }}
|
||||
>
|
||||
<Menu
|
||||
open={copyMenuOpen}
|
||||
compact
|
||||
portal
|
||||
align="end"
|
||||
side={copyTarget.side}
|
||||
anchor={(
|
||||
<button
|
||||
ref={copyButtonRef}
|
||||
type="button"
|
||||
className={css.copyButton}
|
||||
data-json-copy-button
|
||||
data-state={copyState}
|
||||
aria-label={copyTitle}
|
||||
title={`${copyTitle}; right-click for copy options`}
|
||||
onClick={() => void copy(defaultCopyMode)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
copyMenuOpenRef.current = true
|
||||
setCopyMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{copyState === 'copied'
|
||||
? <IconCheckOutline16 size={12} />
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}}
|
||||
onClose={clearCopyTarget}
|
||||
getAnchorRect={() => (
|
||||
copyButtonRef.current as HTMLButtonElement
|
||||
).getBoundingClientRect()}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -115,6 +115,37 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.list.compactList,
|
||||
.submenu.compactList {
|
||||
min-width: 164px;
|
||||
padding: 2px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.compactList .item {
|
||||
min-height: 26px;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.compactList .itemIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.compactList .separator {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.compactList .label {
|
||||
padding: 4px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -71,6 +71,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
|
||||
@@ -81,7 +82,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* by a hairline; they stay visible while the items above scroll.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
@@ -93,6 +94,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
compact?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
}) {
|
||||
@@ -219,7 +221,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
@@ -246,7 +248,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const list = open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
|
||||
@@ -17,10 +17,14 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
export * from './icons/index.tsx'
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Markdown-to-plain-text projection for compact summaries and labels.
|
||||
* Parsing shares the renderer's GFM grammar; raw HTML stays literal, links
|
||||
* keep their labels, images keep alt text, and code keeps its source text.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
|
||||
/** Amount of parsed Markdown content returned by the extractor. */
|
||||
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
|
||||
|
||||
/** Options for {@link extractMarkdownPlainText}. */
|
||||
export interface MarkdownPlainTextOptions {
|
||||
/** Projection boundary; defaults to the complete document. */
|
||||
mode?: MarkdownPlainTextMode
|
||||
}
|
||||
|
||||
interface MarkdownNode {
|
||||
type: string
|
||||
value?: string
|
||||
alt?: string
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
function inlineText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
case 'inlineCode':
|
||||
case 'code':
|
||||
return node.value ?? ''
|
||||
case 'image':
|
||||
case 'imageReference':
|
||||
return node.alt ?? ''
|
||||
case 'break':
|
||||
return '\n'
|
||||
case 'html':
|
||||
return node.value ?? ''
|
||||
default:
|
||||
return node.children?.map(inlineText).join('') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function compactInline(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function blockText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'root':
|
||||
case 'blockquote':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return compactInline(inlineText(node))
|
||||
case 'code':
|
||||
return node.value?.trim() ?? ''
|
||||
case 'list':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'listItem':
|
||||
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
|
||||
case 'table':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'tableRow':
|
||||
return node.children?.map(blockText).join('\t') ?? ''
|
||||
case 'tableCell':
|
||||
return compactInline(inlineText(node))
|
||||
case 'html':
|
||||
return node.value ?? ''
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
default:
|
||||
return compactInline(inlineText(node))
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstParagraph(node: MarkdownNode): string | undefined {
|
||||
if (node.type === 'paragraph') {
|
||||
const text = compactInline(inlineText(node))
|
||||
if (text !== '') return text
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
const text = findFirstParagraph(child)
|
||||
if (text !== undefined) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fullText(root: MarkdownNode): string {
|
||||
return blockText(root)
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GFM Markdown, remove its presentation markup, and preserve raw HTML literally.
|
||||
* @param markdown - Markdown source.
|
||||
* @param options - Optional extraction boundary.
|
||||
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
|
||||
*/
|
||||
export function extractMarkdownPlainText(
|
||||
markdown: string,
|
||||
options: MarkdownPlainTextOptions = {},
|
||||
): string {
|
||||
const { mode = 'all' } = options
|
||||
const root = fromMarkdown(markdown, {
|
||||
extensions: [gfm()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
}) as MarkdownNode
|
||||
const all = fullText(root)
|
||||
switch (mode) {
|
||||
case 'all':
|
||||
return all
|
||||
case 'first-line':
|
||||
return all.split('\n').find(line => line !== '') ?? ''
|
||||
case 'first-paragraph':
|
||||
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ describe('Menu', () => {
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
compact
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
|
||||
@@ -186,6 +187,7 @@ describe('Menu', () => {
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
compact
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'plain', label: 'Plain' },
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JsonTree', () => {
|
||||
it('keeps the top level open and renders expandable value previews', () => {
|
||||
render(
|
||||
<JsonTree
|
||||
label="Payload"
|
||||
data={{
|
||||
nested: { answer: 42 },
|
||||
list: ['alpha', 'beta'],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'Payload' })
|
||||
const rows = within(tree).getAllByRole('treeitem')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]?.textContent).toBe('nested:{answer: 42},')
|
||||
expect(rows[1]?.textContent).toBe('list:["alpha", "beta"]')
|
||||
|
||||
const expanders = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
|
||||
expect(expanders[0]?.tabIndex).toBe(0)
|
||||
expect(expanders[1]?.tabIndex).toBe(-1)
|
||||
|
||||
fireEvent.click(expanders[0] as HTMLElement)
|
||||
expect(within(tree).getAllByRole('treeitem')).toHaveLength(3)
|
||||
expect(screen.getByText('answer:')).toBeDefined()
|
||||
expect(within(tree).getByRole('button', { name: 'Collapse JSON node' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('moves the single tab stop between visible expanders with arrow keys', () => {
|
||||
render(
|
||||
<JsonTree
|
||||
expandTopLevel={false}
|
||||
data={{
|
||||
first: { nested: 1 },
|
||||
second: { nested: 2 },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'JSON' })
|
||||
const root = within(tree).getByRole('button', { name: 'Collapse JSON node' })
|
||||
const children = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
|
||||
|
||||
expect(root.tabIndex).toBe(0)
|
||||
fireEvent.keyDown(root, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(children[0])
|
||||
expect(root.tabIndex).toBe(-1)
|
||||
expect(children[0]?.tabIndex).toBe(0)
|
||||
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowRight' })
|
||||
expect(children[0]?.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowLeft' })
|
||||
expect(children[0]?.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'Enter' })
|
||||
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(root)
|
||||
fireEvent.keyDown(root, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(children[1])
|
||||
})
|
||||
|
||||
it('copies an array element path without recovering data from rendered labels', async () => {
|
||||
render(<JsonTree data={{ list: [{ value: 'x' }, 'tail'] }} />)
|
||||
|
||||
const tree = screen.getByRole('tree')
|
||||
fireEvent.click(within(tree).getByRole('button', { name: 'Expand JSON node' }))
|
||||
const arrayRow = within(tree).getAllByRole('treeitem')
|
||||
.find(row => row.textContent?.startsWith('0:'))
|
||||
expect(arrayRow).toBeDefined()
|
||||
|
||||
fireEvent.mouseOver(arrayRow as HTMLElement)
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
|
||||
fireEvent.contextMenu(copyButton)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy property path' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith('$.list[0]')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders empty containers, JSON-adjacent primitives, and bounded deep previews', () => {
|
||||
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
|
||||
const date = new Date('2026-07-28T00:00:00.000Z')
|
||||
const data = {
|
||||
'': 'empty key',
|
||||
nil: null,
|
||||
text: 'quoted',
|
||||
flag: true,
|
||||
count: 3,
|
||||
big: 4n,
|
||||
date,
|
||||
named: function named() {},
|
||||
missing: undefined,
|
||||
symbol: Symbol('token'),
|
||||
emptyObject: {},
|
||||
emptyArray: [],
|
||||
primitivePreview: {
|
||||
nil: null,
|
||||
flag: false,
|
||||
big: 9n,
|
||||
missing: undefined,
|
||||
},
|
||||
exoticPreview: {
|
||||
symbol: Symbol(),
|
||||
named: function sample() {},
|
||||
anonymous,
|
||||
date,
|
||||
},
|
||||
wideObject: { a: 1, b: 2, c: 3, d: 4, e: 5 },
|
||||
wideArray: [1, 2, 3, 4, 5, 6],
|
||||
deep: { a: { b: { c: 1 } } },
|
||||
}
|
||||
render(<JsonTree copyable={false} data={data} />)
|
||||
|
||||
const text = screen.getByRole('tree').textContent
|
||||
expect(text).toContain('"":\"empty key\"')
|
||||
expect(text).toContain('nil:null')
|
||||
expect(text).toContain('flag:true')
|
||||
expect(text).toContain('count:3')
|
||||
expect(text).toContain('big:4n')
|
||||
expect(text).toContain('date:2026-07-28T00:00:00.000Z')
|
||||
expect(text).toContain('named:function() { }')
|
||||
expect(text).toContain('missing:undefined')
|
||||
expect(text).toContain('symbol:Symbol(token)')
|
||||
expect(text).toContain('emptyObject:{}')
|
||||
expect(text).toContain('emptyArray:[]')
|
||||
expect(text).toContain('primitivePreview:{nil: null, flag: false, big: 9, missing: undefined}')
|
||||
expect(text).toContain('exoticPreview:{symbol: Symbol, named: sample, anonymous: Function, date: }')
|
||||
expect(text).toContain('wideObject:{a: 1, b: 2, c: 3, d: 4, …}')
|
||||
expect(text).toContain('wideArray:[1, 2, 3, 4, 5, …]')
|
||||
expect(text).toContain('deep:{a: {b: {…}}}')
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
fireEvent.mouseOver(screen.getByRole('tree').parentElement as HTMLElement)
|
||||
})
|
||||
|
||||
it('renders child commas and lets a clickable property label toggle its node', () => {
|
||||
render(<JsonTree data={{ parent: { emptyObject: {}, emptyArray: [], scalar: 1, last: 2 } }} />)
|
||||
|
||||
fireEvent.click(screen.getByText('parent:'))
|
||||
const tree = screen.getByRole('tree')
|
||||
const rows = within(tree).getAllByRole('treeitem')
|
||||
expect(rows.find(row => row.textContent === 'emptyObject:{},')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'emptyArray:[],')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'scalar:1,')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'last:2')).toBeDefined()
|
||||
|
||||
fireEvent.click(screen.getByText('parent:'))
|
||||
expect(within(tree).getAllByRole('treeitem')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('assigns the initial array tab stop and supports an empty collapsible root', () => {
|
||||
const first = render(<JsonTree data={['plain', { nested: true }]} />)
|
||||
const tree = screen.getByRole('tree')
|
||||
expect(tree.textContent).toContain('0:"plain"')
|
||||
expect(within(tree).getByRole('button', { name: 'Expand JSON node' }).tabIndex).toBe(0)
|
||||
first.unmount()
|
||||
|
||||
render(<JsonTree expandTopLevel={false} data={{}} />)
|
||||
expect(screen.getByRole('tree').textContent).toBe('{}')
|
||||
expect(screen.queryByRole('button', { name: /JSON node/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('copies primitive and object values in every menu mode', async () => {
|
||||
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
|
||||
render(
|
||||
<JsonTree
|
||||
data={{
|
||||
plain: 'hello',
|
||||
'odd-key': 3,
|
||||
object: { a: 1 },
|
||||
missing: undefined,
|
||||
big: 7n,
|
||||
symbol: Symbol(),
|
||||
symbolNamed: Symbol('token'),
|
||||
named: function named() {},
|
||||
anonymous,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree')
|
||||
const row = (prefix: string) => {
|
||||
const match = within(tree).getAllByRole('treeitem')
|
||||
.find(item => item.textContent?.startsWith(prefix))
|
||||
expect(match).toBeDefined()
|
||||
return match as HTMLElement
|
||||
}
|
||||
const hover = (prefix: string) => {
|
||||
fireEvent.mouseOver(row(prefix))
|
||||
return screen.getByRole('button', { name: /Cop/ })
|
||||
}
|
||||
const select = (name: string) => {
|
||||
const button = screen.getByRole('button', { name: /Cop/ })
|
||||
fireEvent.contextMenu(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
fireEvent.click(hover('plain:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('hello') })
|
||||
|
||||
hover('odd-key:')
|
||||
select('Copy property path')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('$["odd-key"]') })
|
||||
select('Copy JSON')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
|
||||
fireEvent.click(hover('odd-key:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
|
||||
|
||||
fireEvent.click(hover('object:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{\n "a": 1\n}') })
|
||||
select('Copy compact JSON')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{"a":1}') })
|
||||
|
||||
for (const [prefix, expected] of [
|
||||
['missing:', 'undefined'],
|
||||
['big:', '7'],
|
||||
['symbol:', 'Symbol'],
|
||||
['symbolNamed:', 'token'],
|
||||
['named:', 'named'],
|
||||
['anonymous:', 'Function'],
|
||||
] as const) {
|
||||
fireEvent.click(hover(prefix))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(expected) })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports clipboard failure, resets feedback, and clears a prior timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
writeText.mockRejectedValue(new Error('denied'))
|
||||
const view = render(<JsonTree data={{ value: 'x' }} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
fireEvent.mouseOver(row)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(screen.getByRole('button', { name: 'Copy failed' })).toBeDefined()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy failed' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(1_500) })
|
||||
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it('keeps copy placement synchronized and clears stale targets', () => {
|
||||
const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
|
||||
const root = view.container.firstElementChild as HTMLElement
|
||||
const tree = screen.getByRole('tree')
|
||||
const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
|
||||
const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
|
||||
|
||||
Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 })
|
||||
Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 })
|
||||
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 100,
|
||||
height: 100,
|
||||
left: 10,
|
||||
right: 310,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 10,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 91,
|
||||
height: 16,
|
||||
left: 10,
|
||||
right: 200,
|
||||
top: 75,
|
||||
width: 190,
|
||||
x: 10,
|
||||
y: 75,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
fireEvent.mouseOver(firstRow)
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
|
||||
expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px')
|
||||
fireEvent.mouseOver(copyButton)
|
||||
expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
|
||||
fireEvent.mouseOver(firstRow)
|
||||
|
||||
fireEvent.scroll(root)
|
||||
fireEvent.scroll(window)
|
||||
fireEvent.resize(window)
|
||||
|
||||
fireEvent.contextMenu(copyButton)
|
||||
fireEvent.mouseOver(secondRow)
|
||||
fireEvent.mouseOver(root)
|
||||
fireEvent.mouseLeave(root)
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
|
||||
fireEvent.mouseOver(secondRow)
|
||||
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
|
||||
fireEvent.mouseOver(root)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
|
||||
fireEvent.scroll(root)
|
||||
view.rerender(<JsonTree data={{ replacement: 3 }} />)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('copies the fixed root and clears it when the pointer leaves', async () => {
|
||||
const view = render(<JsonTree data={{ value: 1 }} />)
|
||||
const root = view.container.firstElementChild as HTMLElement
|
||||
const openingBracket = root.querySelector<HTMLElement>('[data-json-root-row]')
|
||||
expect(openingBracket).not.toBeNull()
|
||||
|
||||
fireEvent.mouseOver(openingBracket as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy pretty JSON' }))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenCalledWith('{\n "value": 1\n}') })
|
||||
|
||||
fireEvent.mouseLeave(root)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const MARKDOWN = [
|
||||
'# Release notes',
|
||||
'',
|
||||
'First **paragraph** with [a link](https://example.com) and .',
|
||||
'',
|
||||
'- shipped',
|
||||
'- `verified`',
|
||||
'',
|
||||
'```ts',
|
||||
'const ready = true',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
describe('extractMarkdownPlainText', () => {
|
||||
it('projects the complete GFM document without presentation syntax', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
|
||||
'Release notes',
|
||||
'',
|
||||
'First paragraph with a link and diagram.',
|
||||
'',
|
||||
'shipped',
|
||||
'verified',
|
||||
'',
|
||||
'const ready = true',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('selects the first visible line or first semantic paragraph', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
|
||||
.toBe('First paragraph with a link and diagram.')
|
||||
})
|
||||
|
||||
it('preserves raw HTML while removing Markdown presentation markup', () => {
|
||||
const block = [
|
||||
'<background-task-complete id="trajectory-ui-watch">',
|
||||
'Command: pnpm test',
|
||||
'Exit code: 0',
|
||||
'</background-task-complete>',
|
||||
].join('\n')
|
||||
expect(extractMarkdownPlainText(block)).toBe(block)
|
||||
expect(extractMarkdownPlainText('**Status:** <span data-state="ok">ready</span>'))
|
||||
.toBe('Status: <span data-state="ok">ready</span>')
|
||||
expect(extractMarkdownPlainText(block, { mode: 'first-paragraph' }))
|
||||
.toBe('<background-task-complete id="trajectory-ui-watch">')
|
||||
})
|
||||
|
||||
it('projects GFM tables, references, hard breaks, and block structure', () => {
|
||||
const markdown = [
|
||||
'> first\\',
|
||||
'> second with ![diagram][asset] and <span>visible</span>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'| Name | Value |',
|
||||
'| --- | --- |',
|
||||
'| alpha | `1` |',
|
||||
'',
|
||||
'[asset]: diagram.png',
|
||||
].join('\n')
|
||||
expect(extractMarkdownPlainText(markdown)).toBe([
|
||||
'first second with diagram and <span>visible</span>',
|
||||
'',
|
||||
'Name\tValue',
|
||||
'alpha\t1',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
|
||||
README.zh.md: 056827d0110b4360791f1ae08c9bf6f62c00f049
|
||||
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
|
||||
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A 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.
|
||||
|
||||
## 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 render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
|
||||
- **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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
轨迹轮次列表界面框架(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall(瀑布式事件)占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **进行中时,Time 保持空白**:`partial`/`runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式仅在当前视图内部生效(未连接到聊天详情);锚点深链接仍暂缓实现。
|
||||
- **进行中时,Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -33,13 +33,18 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -46,14 +46,40 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagSystem {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.tagUser {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagContext {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-success-primary) 68%,
|
||||
var(--dsw-alias-label-secondary)
|
||||
);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagMessage {
|
||||
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
) 15%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
}
|
||||
|
||||
.tagTool {
|
||||
@@ -64,8 +90,16 @@
|
||||
/* run_code sub-dispatch cells: the business tint plus an indent so the
|
||||
nesting under the parent Tool cell reads at a glance. */
|
||||
.tagSubtool {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-label) 62%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-tertiary) 58%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
}
|
||||
|
||||
.root[data-kind='subtool'] {
|
||||
|
||||
@@ -1,62 +1,40 @@
|
||||
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
|
||||
// ellipsis text, optional Message token metrics, and own-duration time.
|
||||
// Legacy standalone trajectory cell retained for direct consumers and specs.
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
type TrajectoryCellKind,
|
||||
type TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
|
||||
* subtool = one run_code sub-dispatch nested under its Tool cell). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
|
||||
export { formatElapsedSeconds }
|
||||
export type {
|
||||
AssistantMetricDetail,
|
||||
TrajectoryCellKind,
|
||||
TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
context: 'Context',
|
||||
compacted: 'Compacted',
|
||||
message: 'Message',
|
||||
tool: 'Tool',
|
||||
subtool: 'Sub',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
|
||||
system: css.tagSystem,
|
||||
user: css.tagUser,
|
||||
context: css.tagContext,
|
||||
compacted: css.tagSystem,
|
||||
message: css.tagMessage,
|
||||
tool: css.tagTool,
|
||||
subtool: css.tagSubtool,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based step index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/**
|
||||
* Own duration in seconds. `null` means no duration to show (em dash) —
|
||||
* used for in-flight tools and tools missing callTime.
|
||||
*/
|
||||
timeSeconds: number | null
|
||||
/** Message-only: prompt token count. */
|
||||
input?: number
|
||||
/** Message-only: completion token count. */
|
||||
output?: number
|
||||
/** Message-only: reasoning token count (usage column, not a Think cell). */
|
||||
think?: number
|
||||
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
|
||||
* or `+N.1s` otherwise.
|
||||
* @param seconds - duration seconds, or null when absent.
|
||||
* @returns display string.
|
||||
*/
|
||||
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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one trajectory step cell.
|
||||
* @param props - index, kind, text, time, and optional Message metrics.
|
||||
@@ -66,7 +44,20 @@ export function TrajectoryCell({
|
||||
index,
|
||||
kind,
|
||||
text,
|
||||
inputDetail: _inputDetail,
|
||||
promptDetail: _promptDetail,
|
||||
previousPromptDetail: _previousPromptDetail,
|
||||
outputDetail: _outputDetail,
|
||||
thinkingDetail: _thinkingDetail,
|
||||
sourceBlocks: _sourceBlocks,
|
||||
outputBlocks: _outputBlocks,
|
||||
schemaDetail: _schemaDetail,
|
||||
assistantMetrics: _assistantMetrics,
|
||||
result: _result,
|
||||
callId: _callId,
|
||||
isError: _isError,
|
||||
timeSeconds,
|
||||
startedAt: _startedAt,
|
||||
input,
|
||||
output,
|
||||
think,
|
||||
|
||||
@@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css'
|
||||
export interface TrajectoryGroupHeaderProps {
|
||||
/** Group title (`Message`, `Step 1`, …). */
|
||||
title: string
|
||||
/** Secondary summary (`49s`, `2.2s skill`, …). */
|
||||
/** Secondary summary (`49 s`, `2.2 s skill`, …). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.root {
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// TrajectoryStatsHeader: span totals row rendered at the top of both
|
||||
// placeholder view bodies (chrome dissolved into the views — the header is
|
||||
// part of what these views ARE, not registration metadata). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row is quiet
|
||||
// during streaming.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { deriveSpans, deriveSpanStats } from './spans.ts'
|
||||
import css from './TrajectoryStatsHeader.module.css'
|
||||
|
||||
/** Props: the conversation-snapshot selector hook (handed down by the view body). */
|
||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
||||
if (stats.turns === 0) return null
|
||||
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
.root {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.labels {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.labels span {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.labels span:nth-child(1) {
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.labels span:nth-child(2) {
|
||||
top: 21px;
|
||||
}
|
||||
|
||||
.labels span:nth-child(3) {
|
||||
top: 35px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.track:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.lanes {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 7px 0;
|
||||
}
|
||||
|
||||
.turnBoundaries {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.turnBoundary {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-turn-left);
|
||||
width: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.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));
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.span[data-timeline-span='user'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='context'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-success-primary) 68%,
|
||||
var(--dsw-alias-label-secondary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='message'] {
|
||||
background: 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(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-label) 62%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-equal-duration='true'] {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.span[data-selected='false'] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.span[data-current='true'] {
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--dsw-alias-bg-layer-2),
|
||||
0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.span[data-search-match='false'] {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 12%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
-100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent),
|
||||
100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selectionEdges {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hoverLine {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: clamp(
|
||||
0px,
|
||||
calc(var(--trajectory-hover-left) - 1px),
|
||||
calc(100% - 2px)
|
||||
);
|
||||
width: 2px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selectionEdges::before,
|
||||
.selectionEdges::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.selectionEdges::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.selectionEdges::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.selectionEdges[data-dragging='true']::before,
|
||||
.selectionEdges[data-dragging='true']::after {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.selection[data-dragging='true'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 18%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
|
||||
|
||||
import {
|
||||
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||
type PointerEvent, type WheelEvent,
|
||||
} from 'react'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import {
|
||||
deriveTrajectoryTimeline,
|
||||
formatTimelineOffset,
|
||||
type TrajectoryTimelineMode,
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './TrajectoryTimeline.module.css'
|
||||
|
||||
const MINIMUM_DRAG_PX = 3
|
||||
const MINIMUM_ZOOM_OPERATIONS = 4
|
||||
|
||||
interface FractionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
||||
export interface TrajectoryTimelineProps {
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
mode: TrajectoryTimelineMode
|
||||
range: TrajectoryTimeRange | null
|
||||
selectedIndex?: number | null
|
||||
/** Record indexes matching the active ledger search, or null without a query. */
|
||||
searchMatchIndexes?: ReadonlySet<number> | null
|
||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
||||
onRecordFocus?: (index: number) => void
|
||||
}
|
||||
|
||||
function orderedRange(left: number, right: number): FractionRange {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
function clampFraction(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function centeredRange(center: number, width: number): FractionRange {
|
||||
const clampedWidth = Math.min(1, Math.max(0, width))
|
||||
const start = Math.min(
|
||||
Math.max(center - clampedWidth / 2, 0),
|
||||
1 - clampedWidth,
|
||||
)
|
||||
return { start, end: start + clampedWidth }
|
||||
}
|
||||
|
||||
function rangeFraction(
|
||||
range: TrajectoryTimeRange,
|
||||
start: number,
|
||||
duration: number,
|
||||
): FractionRange {
|
||||
return orderedRange(
|
||||
clampFraction((range.start - start) / duration),
|
||||
clampFraction((range.end - start) / duration),
|
||||
)
|
||||
}
|
||||
|
||||
function LaneLabels() {
|
||||
return (
|
||||
<div className={css.labels} aria-hidden="true">
|
||||
<span>Input</span>
|
||||
<span>Model</span>
|
||||
<span>Tools</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
|
||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
turns,
|
||||
mode,
|
||||
range,
|
||||
selectedIndex = null,
|
||||
searchMatchIndexes = null,
|
||||
onRangeChange,
|
||||
onRecordFocus,
|
||||
}: TrajectoryTimelineProps) {
|
||||
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||
const durationByIndex = 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],
|
||||
),
|
||||
),
|
||||
)),
|
||||
[turns],
|
||||
)
|
||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||
useEffect(() => {
|
||||
if (
|
||||
model !== null
|
||||
&& range !== null
|
||||
&& (range.end < model.start || range.start > model.end)
|
||||
) {
|
||||
onRangeChange(null)
|
||||
}
|
||||
}, [model, onRangeChange, range])
|
||||
useEffect(() => {
|
||||
if (model === null) return
|
||||
setViewport(current =>
|
||||
current !== null && (current.end < model.start || current.start > model.end)
|
||||
? null
|
||||
: current)
|
||||
}, [model])
|
||||
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
||||
const viewportDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
|
||||
)
|
||||
const viewportStart = model === null || viewport === null
|
||||
? model?.start ?? 0
|
||||
: Math.min(
|
||||
Math.max(viewport.start, model.start),
|
||||
model.end - viewportDuration,
|
||||
)
|
||||
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
||||
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
|
||||
const committed = model === null || range === null
|
||||
? null
|
||||
: rangeFraction(range, domainStart, domainDuration)
|
||||
const visibleRange = draft ?? committed
|
||||
const activeRange = draft === null
|
||||
? range
|
||||
: {
|
||||
start: domainStart + draft.start * domainDuration,
|
||||
end: domainStart + draft.end * domainDuration,
|
||||
}
|
||||
|
||||
if (model === null) {
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div className={css.track}>
|
||||
<span className={css.empty}>No timing data</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const minimumSelectionFraction = Math.min(
|
||||
1,
|
||||
fullDuration / domainDuration / model.spans.length,
|
||||
)
|
||||
|
||||
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
}
|
||||
|
||||
const commit = (fraction: FractionRange) => {
|
||||
onRangeChange({
|
||||
start: domainStart + fraction.start * domainDuration,
|
||||
end: domainStart + fraction.end * domainDuration,
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchor = fractionAt(event)
|
||||
setHover(anchor)
|
||||
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
setDraft({ start: anchor, end: anchor })
|
||||
}
|
||||
|
||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
const fraction = fractionAt(event)
|
||||
setHover(fraction)
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
setDraft(orderedRange(drag.anchor, fraction))
|
||||
}
|
||||
|
||||
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
const point = fractionAt(event)
|
||||
const selected = orderedRange(drag.anchor, point)
|
||||
setHover(point)
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
|
||||
const committedRange = selected.end - selected.start < minimumSelectionFraction
|
||||
? centeredRange(
|
||||
click ? selected.start : (selected.start + selected.end) / 2,
|
||||
minimumSelectionFraction,
|
||||
)
|
||||
: selected
|
||||
commit(committedRange)
|
||||
if (click) {
|
||||
const timelinePoint = domainStart + selected.start * domainDuration
|
||||
const nearest = model.spans.reduce((candidate, span) => {
|
||||
const candidateDistance = timelinePoint < candidate.start
|
||||
? candidate.start - timelinePoint
|
||||
: timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
|
||||
const spanDistance = timelinePoint < span.start
|
||||
? span.start - timelinePoint
|
||||
: timelinePoint > span.end ? timelinePoint - span.end : 0
|
||||
return spanDistance < candidateDistance ? span : candidate
|
||||
})
|
||||
onRecordFocus?.(nearest.index)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape' || range === null) return
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}
|
||||
|
||||
const onPointerCancel = () => {
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
setHover(null)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
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 })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div
|
||||
className={css.track}
|
||||
aria-label="Timeline overview; drag horizontally to focus events"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerLeave={() => {
|
||||
if (dragRef.current === null) setHover(null)
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
setViewport(null)
|
||||
}}
|
||||
>
|
||||
{hover !== null && draft === null && (
|
||||
<div
|
||||
className={css.hoverLine}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-hover-left': `${hover * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)}
|
||||
{visibleRange !== null && (
|
||||
<>
|
||||
<div
|
||||
className={css.selection}
|
||||
data-dragging={draft === null ? undefined : 'true'}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
||||
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className={css.selectionEdges}
|
||||
data-dragging={draft === null ? undefined : 'true'}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
||||
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={css.turnBoundaries} aria-hidden="true">
|
||||
{model.turnBoundaries
|
||||
.slice(1)
|
||||
.filter(boundary =>
|
||||
boundary.time >= domainStart
|
||||
&& boundary.time <= domainStart + domainDuration)
|
||||
.map(boundary => (
|
||||
<span
|
||||
className={css.turnBoundary}
|
||||
data-turn={boundary.turn}
|
||||
key={boundary.turn}
|
||||
style={{
|
||||
'--trajectory-turn-left':
|
||||
`${(boundary.time - domainStart) / domainDuration * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.lanes} aria-hidden="true">
|
||||
{model.spans
|
||||
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
|
||||
.map((span) => {
|
||||
const left = (span.start - domainStart) / domainDuration
|
||||
const width = (span.end - span.start) / domainDuration
|
||||
const durationMs = durationByIndex.get(span.index)
|
||||
return (
|
||||
<span
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
data-equal-duration={mode === 'time' || undefined}
|
||||
data-current={span.index === selectedIndex || 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'}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
.root {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: var(--dsh-trajectory-toolbar-height);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggle[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 88px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.control[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control:hover:not(:disabled),
|
||||
.control[aria-checked='true'],
|
||||
.control[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.control:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.control:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controlTrack {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
transition: background-color 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.controlThumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
transition: transform 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.controlTrack[data-on='true'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.controlTrack[data-on='true'] .controlThumb {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
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);
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
align-items: center;
|
||||
min-width: 84px;
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.search:hover {
|
||||
border-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.searchInput::-webkit-search-cancel-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Trajectory toolbar: timeline and ledger fold controls. */
|
||||
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TrajectoryToolbar.module.css'
|
||||
|
||||
export interface TrajectoryToolbarProps {
|
||||
/** Whether timeline blocks use recorded durations instead of equal widths. */
|
||||
actualDuration: boolean
|
||||
/** Select recorded-duration or equal-width blocks. */
|
||||
onActualDurationChange: (actualDuration: boolean) => void
|
||||
/** Whether recorded timing retains idle gaps between user turns. */
|
||||
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. */
|
||||
onToggleAllAssistants: () => void
|
||||
/** Current live ledger search query. */
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky trajectory toolbar.
|
||||
* @param props - rendered counts and whole-list fold state.
|
||||
* @returns the toolbar element.
|
||||
*/
|
||||
export function TrajectoryToolbar({
|
||||
actualDuration,
|
||||
onActualDurationChange,
|
||||
actualTime,
|
||||
onActualTimeChange,
|
||||
collapsibleTurns,
|
||||
allTurnsCollapsed,
|
||||
onToggleAllTurns,
|
||||
collapsibleAssistants,
|
||||
allAssistantsCollapsed,
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.inner}>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-label="Use actual duration"
|
||||
aria-pressed={actualDuration}
|
||||
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
|
||||
onClick={() => { onActualDurationChange(!actualDuration) }}
|
||||
>
|
||||
<svg
|
||||
className={css.toggleIcon}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="5.25" />
|
||||
<path d="M8 4.75V8l2.25 1.5" />
|
||||
</svg>
|
||||
Duration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.control}
|
||||
role="switch"
|
||||
aria-checked={actualTime}
|
||||
hidden
|
||||
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||
>
|
||||
<span>Actual time</span>
|
||||
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
|
||||
<span className={css.controlThumb} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
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">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Turns
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
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">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Calls
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
<IconSearchOutline16 size={11} className={css.searchIcon} />
|
||||
<input
|
||||
type="search"
|
||||
className={css.searchInput}
|
||||
aria-label="Search trajectory"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +1,509 @@
|
||||
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryCell } from './TrajectoryCell.tsx'
|
||||
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext,
|
||||
SessionHistoryFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
} from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
type TrajectoryUsage,
|
||||
} from './TrajectoryTable.tsx'
|
||||
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import {
|
||||
trajectoryTimelineFocusIndexes,
|
||||
type TrajectoryTimelineMode,
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||
[nodes, partial, runningCalls, codeDispatches],
|
||||
)
|
||||
if (turns.length === 0) {
|
||||
return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: { history: SessionHistoryFace }
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
||||
const usage = value as UsageLike | undefined
|
||||
if (usage === undefined) return undefined
|
||||
return {
|
||||
...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }),
|
||||
...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }),
|
||||
...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }),
|
||||
...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }),
|
||||
...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }),
|
||||
}
|
||||
}
|
||||
|
||||
function addUsage(
|
||||
total: TrajectoryUsage | undefined,
|
||||
usage: TrajectoryUsage | undefined,
|
||||
): TrajectoryUsage | undefined {
|
||||
if (usage === undefined) return total
|
||||
return {
|
||||
...(total?.input === undefined && usage.input === undefined
|
||||
? {}
|
||||
: { input: (total?.input ?? 0) + (usage.input ?? 0) }),
|
||||
...(total?.cacheRead === undefined && usage.cacheRead === undefined
|
||||
? {}
|
||||
: { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }),
|
||||
...(total?.cacheWrite === undefined && usage.cacheWrite === undefined
|
||||
? {}
|
||||
: { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }),
|
||||
...(total?.output === undefined && usage.output === undefined
|
||||
? {}
|
||||
: { output: (total?.output ?? 0) + (usage.output ?? 0) }),
|
||||
...(total?.reasoning === undefined && usage.reasoning === undefined
|
||||
? {}
|
||||
: { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }),
|
||||
}
|
||||
}
|
||||
|
||||
function searchableJson(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
): ReadonlySet<number> | null {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return null
|
||||
const matches = new Set<number>()
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (cell.requestOnly === true) continue
|
||||
const blocks = [
|
||||
...(cell.sourceBlocks ?? []),
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
const text = [
|
||||
`turn ${turn.turn}`,
|
||||
group.title,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : undefined,
|
||||
cell.text,
|
||||
cell.inputDetail,
|
||||
cell.outputDetail,
|
||||
cell.thinkingDetail,
|
||||
cell.schemaDetail,
|
||||
cell.result,
|
||||
cell.callId,
|
||||
...blocks.flatMap(block => [
|
||||
block.type,
|
||||
block.content,
|
||||
block.callId,
|
||||
block.toolName,
|
||||
block.imageAlt,
|
||||
]),
|
||||
searchableJson(cell.messageSource),
|
||||
searchableJson(cell.promptDetail),
|
||||
searchableJson(cell.previousPromptDetail),
|
||||
].filter((value): value is string => typeof value === 'string')
|
||||
.join('\n')
|
||||
.toLocaleLowerCase()
|
||||
if (terms.every(term => text.includes(term))) matches.add(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, loadAllHistory,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [timelineSelection, setTimelineSelection] = useState<{
|
||||
branchId: number
|
||||
range: TrajectoryTimeRange
|
||||
} | null>(null)
|
||||
const [actualDuration, setActualDuration] = useState(false)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const nodes = inspection.eventNodes
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const codeDispatches = inspection.codeDispatches
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
const requests = inspection.requests
|
||||
const callSchemas = inspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => inspection.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
[contexts],
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
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) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, inspection])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
),
|
||||
[currentBranch, requests],
|
||||
)
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
for (const node of context.nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
const requestsByStep = new Map(
|
||||
requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
)
|
||||
const orderedRequests = [
|
||||
...requests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
node: request.purpose === 'assistant'
|
||||
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
|
||||
: undefined,
|
||||
})),
|
||||
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
|
||||
requestsByStep.has(key)
|
||||
? []
|
||||
: [{
|
||||
seq: node.seq,
|
||||
request: undefined,
|
||||
node,
|
||||
}],
|
||||
),
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
const numbered: TrajectoryRequestNumber[] = []
|
||||
let cumulativeUsage: TrajectoryUsage | undefined
|
||||
for (const [index, entry] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
|
||||
cumulativeUsage = addUsage(cumulativeUsage, usage)
|
||||
if (entry.request?.purpose !== 'compaction') {
|
||||
const request = entry.request
|
||||
const node = entry.node
|
||||
const turn = request?.turn ?? node?.turn
|
||||
const step = request?.step ?? node?.step
|
||||
if (turn === undefined || step === undefined) continue
|
||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||
numbered.push({
|
||||
seq: entry.seq,
|
||||
turn,
|
||||
step,
|
||||
group: `Step ${step}`,
|
||||
number: index + 1,
|
||||
...(request?.status === undefined ? {} : { status: request.status }),
|
||||
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
|
||||
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
|
||||
...(request?.error === undefined ? {} : { error: request.error }),
|
||||
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
|
||||
...(request?.retry === undefined ? {} : { retry: request.retry }),
|
||||
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
|
||||
...(request?.retryDelayMs === undefined
|
||||
? {}
|
||||
: { retryDelayMs: request.retryDelayMs }),
|
||||
...(provider === undefined ? {} : { provider }),
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const request = entry.request
|
||||
numbered.push({
|
||||
seq: request.startSeq,
|
||||
turn: request.turn,
|
||||
step: 0,
|
||||
group: `Compaction ${request.startSeq}`,
|
||||
number: index + 1,
|
||||
purpose: 'compaction',
|
||||
status: request.status,
|
||||
startedAt: request.startedAt,
|
||||
completedAt: request.completedAt,
|
||||
...(request.error === undefined ? {} : { error: request.error }),
|
||||
resultSeq: request.startSeq,
|
||||
...(request.provenance?.provider === undefined
|
||||
? {}
|
||||
: { provider: request.provenance.provider }),
|
||||
...(request.provenance?.model === undefined
|
||||
? {}
|
||||
: { model: request.provenance.model }),
|
||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
])
|
||||
const requestNumbers = globalRequestNumbers
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial,
|
||||
runningCalls,
|
||||
requests: selectedRequests,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||
? actualTime ? 'actual' : 'duration'
|
||||
: actualTime ? 'time' : 'sequence'
|
||||
const searchMatchIndexes = useMemo(
|
||||
() => searchMatches(turns, searchQuery),
|
||||
[searchQuery, turns],
|
||||
)
|
||||
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
||||
? timelineSelection.range
|
||||
: null
|
||||
const timelineFocusIndexes = useMemo(
|
||||
() => timelineRange === null
|
||||
? null
|
||||
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
|
||||
[timelineMode, timelineRange, turns],
|
||||
)
|
||||
const handleRecordSelect = useCallback((index: number) => {
|
||||
if (
|
||||
timelineFocusIndexes !== null
|
||||
&& !timelineFocusIndexes.has(index)
|
||||
) {
|
||||
setTimelineSelection(null)
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const ledger = ledgerRef.current
|
||||
if (ledger === null) return
|
||||
const focusedRows = [
|
||||
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
|
||||
]
|
||||
const first = focusedRows.at(0)
|
||||
const last = focusedRows.at(-1)
|
||||
if (first === undefined || last === undefined) return
|
||||
const focusHeight =
|
||||
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
|
||||
if (focusHeight > ledger.clientHeight) {
|
||||
if (typeof first.scrollIntoView === 'function') {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
return
|
||||
}
|
||||
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
|
||||
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
.filter(turn =>
|
||||
turn.groups.reduce(
|
||||
(count, group) =>
|
||||
count + group.cells.filter(cell =>
|
||||
cell.requestOnly !== true && cell.kind !== 'system').length,
|
||||
0,
|
||||
) > 1)
|
||||
.map(turn => turn.turn),
|
||||
[turns],
|
||||
)
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||
const collapsibleAssistantIds = useMemo(() => {
|
||||
const ids: number[] = []
|
||||
for (const turn of turns) {
|
||||
const cells = turn.groups.flatMap(group => group.cells)
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
if (cell?.kind !== 'message') continue
|
||||
const next = cells[i + 1]
|
||||
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [turns])
|
||||
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||
|
||||
const toggleTurn = (turn: number) => {
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(turn)) collapsed.delete(turn)
|
||||
else collapsed.add(turn)
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllTurns = () => {
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allTurnsCollapsed) {
|
||||
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
|
||||
} else {
|
||||
for (const turn of collapsibleTurnIds) collapsed.add(turn)
|
||||
}
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAssistant = (index: number) => {
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(index)) collapsed.delete(index)
|
||||
else collapsed.add(index)
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllAssistants = () => {
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allAssistantsCollapsed) {
|
||||
for (const index of collapsibleAssistantIds) collapsed.delete(index)
|
||||
} else {
|
||||
for (const index of collapsibleAssistantIds) collapsed.add(index)
|
||||
}
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{turns.map(turn => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap(group => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map(cell => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
</TrajectoryTurn>
|
||||
))}
|
||||
<TrajectoryToolbar
|
||||
actualDuration={actualDuration}
|
||||
onActualDurationChange={(nextActualDuration) => {
|
||||
setActualDuration(nextActualDuration)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
actualTime={actualTime}
|
||||
onActualTimeChange={(nextActualTime) => {
|
||||
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}
|
||||
mode={timelineMode}
|
||||
range={timelineRange}
|
||||
selectedIndex={selectedTimelineIndex}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
||||
}}
|
||||
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' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div ref={ledgerRef} className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
timelineFocusIndexes={timelineFocusIndexes}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||
onRecordSelect={handleRecordSelect}
|
||||
onClearSelection={() => { setTimelineSelection(null) }}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// WaterfallView: span stats header over per-turn node-count lanes (P-I
|
||||
// stand-in for duration lanes; deviation ledger #3). run_code turns
|
||||
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
|
||||
// carries per-sub-call wall time, so each sub-span's width is its real
|
||||
// duration against the parent turn's dispatch window.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans, deriveSubSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import css from './views.module.css'
|
||||
|
||||
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
|
||||
const PX_PER_NODE = 14
|
||||
const MIN_BAR_PX = 8
|
||||
/** Sub-span lane width budget (the parent window scales into this). */
|
||||
const SUB_LANE_PX = 220
|
||||
|
||||
/** Optional density override (test/standalone knob; the register site passes nothing). */
|
||||
export interface WaterfallExtraProps {
|
||||
/** Bar-lane density in px per node; defaults to 14. */
|
||||
pxPerNode?: number
|
||||
}
|
||||
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span, i) => (
|
||||
<div key={span.turn}>
|
||||
<div className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
{span.calls > 0 && (
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(subSpans.get(span.turn) ?? []).map(lane => (
|
||||
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
||||
<span className={css.subTag}>{lane.name}</span>
|
||||
<span
|
||||
className={`${css.bar} ${css.barSub}`}
|
||||
data-timing={lane.timing}
|
||||
style={{
|
||||
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
|
||||
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
|
||||
}}
|
||||
title={lane.timing === 'measured'
|
||||
/* durationMs is non-null exactly when timing is measured. */
|
||||
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s`
|
||||
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
||||
|
||||
import type {
|
||||
ConversationContext, ConversationNode, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
||||
export interface TrajectoryContextBranch {
|
||||
id: number
|
||||
contexts: readonly ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Seq that opened this branch; earlier requests require retained surface provenance. */
|
||||
startSeq: number
|
||||
/** Exact pre-rewind surface records inherited by this branch. */
|
||||
retainedSurfaceSeqs: ReadonlySet<number>
|
||||
}
|
||||
|
||||
interface MutableBranch {
|
||||
id: number
|
||||
contexts: ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: Map<number, ConversationNode>
|
||||
startSeq: number
|
||||
retainedSurfaceSeqs: Set<number>
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||
if (node.kind !== 'context') return false
|
||||
const source = node.source
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/**
|
||||
* Join context generations across compaction/rewrite operations and split only at rewind.
|
||||
* @param contexts - Append-only context generations from the runtime fold.
|
||||
* @returns Rewind-delimited branches in creation order.
|
||||
*/
|
||||
export function deriveTrajectoryContextBranches(
|
||||
contexts: readonly ConversationContext[],
|
||||
): readonly TrajectoryContextBranch[] {
|
||||
const mutable: MutableBranch[] = []
|
||||
for (const context of contexts) {
|
||||
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
||||
if (startsBranch) {
|
||||
const previous = mutable.at(-1)
|
||||
const retainedSurfaceSeqs = new Set(
|
||||
context.nodes
|
||||
.filter(node =>
|
||||
context.originSeq !== undefined && node.seq < context.originSeq,
|
||||
)
|
||||
.map(node => node.seq),
|
||||
)
|
||||
const inheritedNodes = previous === undefined
|
||||
? []
|
||||
: [...previous.nodes.values()].filter(node =>
|
||||
retainedSurfaceSeqs.has(node.seq),
|
||||
)
|
||||
mutable.push({
|
||||
id: context.id,
|
||||
contexts: [context],
|
||||
latest: context,
|
||||
nodes: new Map(
|
||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||
.map(node => [node.seq, node]),
|
||||
),
|
||||
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||
retainedSurfaceSeqs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const branch = mutable.at(-1)
|
||||
if (branch === undefined) continue
|
||||
branch.contexts.push(context)
|
||||
branch.latest = context
|
||||
for (const node of context.nodes) {
|
||||
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
|
||||
}
|
||||
}
|
||||
return mutable.map(branch => ({
|
||||
id: branch.id,
|
||||
contexts: branch.contexts,
|
||||
latest: branch.latest,
|
||||
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
|
||||
startSeq: branch.startSeq,
|
||||
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a provider request belongs to one rewind branch.
|
||||
* @param branch - Branch carrying exact inherited surface provenance.
|
||||
* @param request - Provider request to classify.
|
||||
* @returns Whether the request began on this branch or produced a retained surface record.
|
||||
*/
|
||||
export function trajectoryBranchContainsRequest(
|
||||
branch: TrajectoryContextBranch,
|
||||
request: RequestView,
|
||||
): boolean {
|
||||
if (request.startSeq >= branch.startSeq) return true
|
||||
return (
|
||||
request.resultSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
|
||||
) || (
|
||||
request.replacementSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Browser trajectory plugin contributing two entries to the conversation
|
||||
* view slot without defining a service.
|
||||
* Browser trajectory plugin contributing one entry to the conversation view
|
||||
* slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
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 { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
@@ -16,17 +16,25 @@ import { WaterfallView } from './WaterfallView.tsx'
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs).
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
* rides the slot service's effect wrapper, so plugin unload removes the tab.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
hooks: { history },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
}
|
||||
@@ -3,12 +3,19 @@
|
||||
* own-duration times, in-flight partial/runningCalls, and group descriptions.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
ConversationSnapshot,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
RequestView,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
|
||||
import type {
|
||||
TrajectoryCellProps,
|
||||
TrajectorySourceBlock,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** One Message or Step group inside a turn. */
|
||||
export interface TrajectoryGroupModel {
|
||||
@@ -28,12 +35,16 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
requests?: readonly RequestView[]
|
||||
callSchemas?: RequestInspectionSnapshot['callSchemas']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
@@ -46,15 +57,85 @@ interface LaidCell {
|
||||
callId?: string
|
||||
}
|
||||
|
||||
interface LaidGroup {
|
||||
title: string
|
||||
laid: LaidCell[]
|
||||
}
|
||||
|
||||
interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
>
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'node'
|
||||
seq: number
|
||||
node: ConversationSnapshot['nodes'][number]
|
||||
nodeIndex: number
|
||||
}
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: RequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
request: RequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: RequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
return entry.kind === 'system' && entry.change.kind === 'initial'
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: entry.seq
|
||||
}
|
||||
|
||||
function inputCellDetail(node: InputNode): Pick<
|
||||
TrajectoryCellProps,
|
||||
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
|
||||
> {
|
||||
return {
|
||||
text: summarizeContent(node.content),
|
||||
sourceSeq: node.seq,
|
||||
messageSource: node.source,
|
||||
inputDetail: detailContent(node.content),
|
||||
sourceBlocks: node.content.map(block => sourceBlock(block)),
|
||||
timeSeconds: 0,
|
||||
startedAt: finiteTime(node.time),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||||
* @param input - nodes plus in-flight partial/runningCalls.
|
||||
* @returns turns ordered by first appearance.
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const { nodes, partial, runningCalls, codeDispatches } = input
|
||||
const {
|
||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
const callStartById = new Map<string, number>()
|
||||
for (const result of resultByCall.values()) {
|
||||
const startedAt = finiteTime(result.callTime)
|
||||
if (startedAt !== null) callStartById.set(result.callId, startedAt)
|
||||
}
|
||||
for (const call of runningCalls) {
|
||||
const startedAt = finiteTime(call.time)
|
||||
if (startedAt !== null) callStartById.set(call.callId, startedAt)
|
||||
}
|
||||
const turns = new Map<number, TurnBucket>()
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
@@ -62,26 +143,171 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const bucket = (turn: number) => {
|
||||
let entry = turns.get(turn)
|
||||
if (entry === undefined) {
|
||||
entry = { message: [], steps: new Map() }
|
||||
entry = { groups: [] }
|
||||
turns.set(turn, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||||
bucket(turn).message.push(laid)
|
||||
const groups = bucket(turn).groups
|
||||
const last = groups.at(-1)
|
||||
if (last?.title === 'Message') {
|
||||
last.laid.push(laid)
|
||||
return
|
||||
}
|
||||
groups.push({ title: 'Message', laid: [laid] })
|
||||
}
|
||||
const pushStep = (turn: number, step: number, laid: LaidCell) => {
|
||||
const steps = bucket(turn).steps
|
||||
const list = steps.get(step) ?? []
|
||||
list.push(laid)
|
||||
steps.set(step, list)
|
||||
const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||||
if (laid.length === 0) return
|
||||
const groups = bucket(turn).groups
|
||||
const title = `Step ${step}`
|
||||
const existing = groups.find(group => group.title === title)
|
||||
if (existing !== undefined) {
|
||||
existing.laid.push(...laid)
|
||||
return
|
||||
}
|
||||
groups.push({ title, laid: [...laid] })
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = 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 (node === undefined) continue
|
||||
const representedRequests = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' && node.step > 0) {
|
||||
representedRequests.add(`${node.turn}\u0000${node.step}`)
|
||||
}
|
||||
}
|
||||
if (partial !== null && partial.step > 0) {
|
||||
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
|
||||
}
|
||||
for (const call of runningCalls) {
|
||||
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
|
||||
}
|
||||
|
||||
const entries: OrderedLayoutEntry[] = [
|
||||
...nodes.map((node, nodeIndex) => ({
|
||||
kind: 'node' as const,
|
||||
seq: node.seq,
|
||||
node,
|
||||
nodeIndex,
|
||||
})),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
seq: request.promptChange.seq,
|
||||
request,
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
.map(request => ({
|
||||
kind: 'request' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'request') {
|
||||
const { request } = entry
|
||||
pushStep(request.turn, request.step, [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
sourceSeq: request.startSeq,
|
||||
requestOnly: true,
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
},
|
||||
}])
|
||||
prevAbsTime = finiteTime(request.completedAt)
|
||||
?? finiteTime(request.startedAt)
|
||||
?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'system') {
|
||||
const { change, request } = entry
|
||||
const turn = change.kind === 'initial'
|
||||
? firstVisibleTurn(nodes, partial)
|
||||
: enclosingPromptTurn(nodes, change.seq, partial)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(change.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'system',
|
||||
text: promptChangeLabel(change),
|
||||
sourceSeq: change.seq,
|
||||
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
|
||||
...(change.previous === undefined
|
||||
? {}
|
||||
: { previousPromptDetail: change.previous }),
|
||||
timeSeconds: 0,
|
||||
startedAt: finiteTime(change.time),
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'compaction') {
|
||||
const request = entry.request
|
||||
const rawOutput = request.rawOutput ?? request.summary
|
||||
const thinkingDetail = rawOutput === undefined
|
||||
? ''
|
||||
: detailReasoning(rawOutput)
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'compacted',
|
||||
text: request.status === 'running'
|
||||
? 'Compacting context…'
|
||||
: request.status === 'error'
|
||||
? request.error ?? 'Compaction failed'
|
||||
: request.summary === undefined
|
||||
? 'Context compacted'
|
||||
: summarizeContent(request.summary),
|
||||
sourceSeq: request.startSeq,
|
||||
...(request.summary === undefined
|
||||
? {}
|
||||
: {
|
||||
outputDetail: detailContent(request.summary),
|
||||
outputBlocks: request.summary.map(block => sourceBlock(block)),
|
||||
}),
|
||||
...(thinkingDetail === '' ? {} : { thinkingDetail }),
|
||||
...(rawOutput === undefined
|
||||
? {}
|
||||
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
}
|
||||
attachUsage(cell, request.usage as UsageLike | undefined)
|
||||
bucket(request.turn).groups.push({
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
}],
|
||||
})
|
||||
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
const { node, nodeIndex: i } = entry
|
||||
if (node.kind === 'user' || node.kind === 'steering') {
|
||||
// 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.
|
||||
@@ -91,19 +317,22 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
timeSeconds: 0,
|
||||
index: ++index,
|
||||
kind: 'user',
|
||||
...inputCellDetail(node),
|
||||
opensTurn: node.kind === 'user',
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
}
|
||||
const laidList = withSubCalls(
|
||||
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
|
||||
codeDispatches,
|
||||
)
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(node.turn, laid)
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
@@ -111,30 +340,47 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
// No trajectory cell, but the surface still advances the duration cursor.
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'context',
|
||||
...inputCellDetail(node),
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
pushStep(0, 1, {
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: finiteTime(node.callTime ?? node.time),
|
||||
...(toolName !== undefined ? { toolName } : {}),
|
||||
callId: node.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
sourceSeq: node.seq,
|
||||
text: node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
||||
outputDetail: detailResult(node),
|
||||
outputBlocks: node.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(node),
|
||||
callId: node.callId,
|
||||
isError: node.isError,
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
startedAt: finiteTime(node.callTime),
|
||||
},
|
||||
})
|
||||
}]
|
||||
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
||||
pushStep(0, 1, laid)
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
pushStep(0, 1, laidList)
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
@@ -145,11 +391,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
|
||||
turn: partial.turn, step: partial.step, blocks: partial.blocks,
|
||||
}
|
||||
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
|
||||
for (const laid of laidList) {
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
|
||||
else pushMessage(partial.turn, laid)
|
||||
}
|
||||
const laidList = expandAssistant(
|
||||
fake,
|
||||
index + 1,
|
||||
prevAbsTime,
|
||||
resultByCall,
|
||||
callStartById,
|
||||
{ streaming: true },
|
||||
)
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(partial.turn, laid)
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
}
|
||||
@@ -157,7 +408,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const seenCalls = collectCallIds(turns)
|
||||
for (const call of runningCalls) {
|
||||
if (seenCalls.has(call.callId)) continue
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: null,
|
||||
toolName: call.name,
|
||||
callId: call.callId,
|
||||
@@ -165,63 +416,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: summarizeCall(call.name, call.argsRaw),
|
||||
inputDetail: call.argsRaw,
|
||||
callId: call.callId,
|
||||
timeSeconds: null,
|
||||
startedAt: finiteTime(call.time),
|
||||
},
|
||||
})
|
||||
}]
|
||||
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
if (call.step > 0) pushStep(call.turn, call.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(call.turn, laid)
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
const prologue = turns.get(0)
|
||||
if (prologue !== undefined) {
|
||||
turns.delete(0)
|
||||
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
|
||||
message: [],
|
||||
steps: new Map(),
|
||||
})
|
||||
const emptyTurn = (): TurnBucket => ({ groups: [] })
|
||||
const first = turns.get(1) ?? emptyTurn()
|
||||
first.message = [...prologue.message, ...first.message]
|
||||
for (const [step, cells] of prologue.steps) {
|
||||
const existing = first.steps.get(step) ?? []
|
||||
first.steps.set(step, [...cells, ...existing])
|
||||
}
|
||||
first.groups = [...prologue.groups, ...first.groups]
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
for (const entry of turns.values()) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||||
}
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
}
|
||||
|
||||
function attachToolSchema(
|
||||
laid: LaidCell,
|
||||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||||
): void {
|
||||
if (laid.callId === undefined || callSchemas === undefined) return
|
||||
const schema = callSchemas.get(laid.callId)
|
||||
if (schema === undefined) return
|
||||
laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
||||
entry: TurnBucket,
|
||||
): TrajectoryTurnModel {
|
||||
const groups: TrajectoryGroupModel[] = []
|
||||
if (entry.message.length > 0) {
|
||||
const description = groupDescription(entry.message)
|
||||
groups.push({
|
||||
title: 'Message',
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: entry.message.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
|
||||
const laid = entry.steps.get(step) ?? []
|
||||
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||||
const description = groupDescription(laid)
|
||||
groups.push({
|
||||
title: `Step ${step}`,
|
||||
return {
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: laid.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
|
||||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
const parts: string[] = []
|
||||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
||||
@@ -256,8 +511,8 @@ 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`
|
||||
if (Number.isInteger(rounded)) return `${rounded} s`
|
||||
return `${rounded.toFixed(1)} s`
|
||||
}
|
||||
|
||||
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
|
||||
@@ -267,8 +522,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
|
||||
}
|
||||
|
||||
/** Epoch-ms usable as an absolute time, else null. */
|
||||
function finiteTime(time: number): number | null {
|
||||
return Number.isFinite(time) ? time : null
|
||||
function finiteTime(time: number | null | undefined): number | null {
|
||||
return typeof time === 'number' && Number.isFinite(time) ? time : null
|
||||
}
|
||||
|
||||
function expandAssistant(
|
||||
@@ -276,66 +531,175 @@ function expandAssistant(
|
||||
startIndex: number,
|
||||
prevAbsTime: number | null,
|
||||
results: Map<string, ToolResultNode>,
|
||||
callStarts: ReadonlyMap<string, number>,
|
||||
opts?: { streaming?: boolean },
|
||||
): LaidCell[] {
|
||||
const out: LaidCell[] = []
|
||||
let index = startIndex - 1
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
const streaming = opts?.streaming === true
|
||||
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
|
||||
const recordedStart = finiteTime(node.timing?.stepStartTime)
|
||||
const messageDuration = streaming
|
||||
? null
|
||||
: durationSeconds(node.time, recordedStart ?? prevAbsTime)
|
||||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||||
let usageAttached = false
|
||||
const messageText = node.blocks
|
||||
.filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
|
||||
.map(block => block.kind === 'text' ? block.text : '')
|
||||
.join('\n\n')
|
||||
const thinkingText = node.blocks
|
||||
.filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
|
||||
.map(block => block.kind === 'reasoning' ? block.text : '')
|
||||
.join('\n\n')
|
||||
const message: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
sourceSeq: node.seq,
|
||||
text: messageText !== ''
|
||||
? summarizeText(messageText)
|
||||
: thinkingText !== ''
|
||||
? summarizeText(thinkingText)
|
||||
: summarizeAssistantActivity(node.blocks),
|
||||
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
||||
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
||||
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
||||
timeSeconds: messageDuration,
|
||||
startedAt: recordedStart,
|
||||
}
|
||||
attachUsage(message, usage)
|
||||
message.assistantMetrics = {
|
||||
timingRecorded: node.timing !== undefined,
|
||||
stepStartTime: node.timing?.stepStartTime ?? null,
|
||||
firstTokenTime: node.timing?.firstTokenTime ?? null,
|
||||
completedTime: streaming ? null : finiteTime(node.time),
|
||||
usageProvided: usage !== undefined,
|
||||
outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell: message })
|
||||
|
||||
for (const block of node.blocks) {
|
||||
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
|
||||
if (block.kind === 'reasoning') continue
|
||||
if (block.kind === 'text') {
|
||||
if (block.text === '' && streaming) continue
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: summarizeText(block.text),
|
||||
timeSeconds: messageDuration,
|
||||
}
|
||||
if (!usageAttached) {
|
||||
attachUsage(cell, usage)
|
||||
usageAttached = usage !== undefined
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
continue
|
||||
}
|
||||
if (block.kind === 'tool-call') {
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = streaming
|
||||
? null
|
||||
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
|
||||
? result.callTime
|
||||
: nodeAbs)
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
// Text and reasoning belong to the one Assistant record emitted above.
|
||||
if (block.kind !== 'tool-call') continue
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = finiteTime(callStarts.get(block.callId))
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
callId: block.callId,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
inputDetail: block.argsRaw,
|
||||
callId: block.callId,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
timeSeconds: toolDuration,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0 && !streaming) {
|
||||
// Reasoning-only / empty success still owns provider usage on the Message row.
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
|
||||
}
|
||||
attachUsage(cell, usage)
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
...(result !== undefined
|
||||
? {
|
||||
outputDetail: detailResult(result),
|
||||
outputBlocks: result.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(result),
|
||||
isError: result.isError,
|
||||
}
|
||||
: {}),
|
||||
timeSeconds: toolDuration,
|
||||
startedAt: callAbs,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||||
const tools = new Map<string, number>()
|
||||
for (const block of blocks) {
|
||||
if (block.kind !== 'tool-call') continue
|
||||
tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
|
||||
}
|
||||
if (tools.size > 0) {
|
||||
return 'Tool call only'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function promptChangeLabel(change: RequestPromptChange): string {
|
||||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||||
if (change.kind === 'system') return 'System Prompt Updated'
|
||||
if (change.kind === 'tools') return 'Tools Updated'
|
||||
return 'System Prompt and Tools Updated'
|
||||
}
|
||||
|
||||
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { type: 'text', content: block.text }
|
||||
case 'reasoning': return { type: 'thinking', content: block.text }
|
||||
case 'tool-call': return {
|
||||
type: 'tool-call',
|
||||
content: block.argsRaw,
|
||||
callId: block.callId,
|
||||
toolName: block.name,
|
||||
}
|
||||
case 'other': return sourceBlock(block.block)
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBlock(value: unknown): TrajectorySourceBlock {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return { type: 'unknown', content: stringifySourceValue(value) }
|
||||
}
|
||||
const block = value as Record<string, unknown>
|
||||
const type = typeof block.type === 'string' ? block.type : 'unknown'
|
||||
if (typeof block.text === 'string') {
|
||||
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
|
||||
}
|
||||
const imageSrc = sourceImage(block)
|
||||
const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
|
||||
return {
|
||||
type,
|
||||
content: imageSrc === undefined ? stringifySourceValue(value) : '',
|
||||
...(imageSrc !== undefined ? { imageSrc } : {}),
|
||||
...(imageAlt !== undefined ? { imageAlt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sourceImage(block: Record<string, unknown>): string | undefined {
|
||||
if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
|
||||
for (const candidate of [block.url, block.image_url]) {
|
||||
if (typeof candidate === 'string') return safeImageSource(candidate)
|
||||
}
|
||||
if (typeof block.data === 'string') {
|
||||
const mediaType = [block.mimeType, block.mediaType, block.media_type]
|
||||
.find((candidate): candidate is string => typeof candidate === 'string')
|
||||
?? 'image/png'
|
||||
return safeImageSource(
|
||||
block.data.startsWith('data:')
|
||||
? block.data
|
||||
: `data:${mediaType};base64,${block.data}`,
|
||||
)
|
||||
}
|
||||
if (typeof block.source !== 'object' || block.source === null) return undefined
|
||||
const source = block.source as Record<string, unknown>
|
||||
if (typeof source.url === 'string') return safeImageSource(source.url)
|
||||
if (typeof source.data !== 'string') return undefined
|
||||
const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
|
||||
return safeImageSource(`data:${mediaType};base64,${source.data}`)
|
||||
}
|
||||
|
||||
function safeImageSource(value: string): string | undefined {
|
||||
if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
|
||||
try {
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === 'http:' || protocol === 'https:' ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stringifySourceValue(value: unknown): string {
|
||||
const json = JSON.stringify(value, null, 2)
|
||||
return json || String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn that encloses a user/message: next assistant/steering turn, else the
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
@@ -357,10 +721,37 @@ function enclosingUserTurn(
|
||||
return 1
|
||||
}
|
||||
|
||||
function enclosingPromptTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
seq: number,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const next = nodes.find(node =>
|
||||
node.seq > seq && node.kind === 'assistant' && node.step > 0)
|
||||
if (next?.kind === 'assistant') return next.turn
|
||||
return partial?.turn ?? 1
|
||||
}
|
||||
|
||||
/** Earliest raw turn represented by the selected trajectory branch. */
|
||||
function firstVisibleTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const turns = nodes.flatMap(node =>
|
||||
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
|
||||
? [node.turn]
|
||||
: [],
|
||||
)
|
||||
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
|
||||
return turns.length === 0 ? 1 : Math.min(...turns)
|
||||
}
|
||||
|
||||
/** Copy provider usage onto a Message cell when present. */
|
||||
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
|
||||
if (usage === undefined) return
|
||||
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
|
||||
if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens
|
||||
if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens
|
||||
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
|
||||
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
|
||||
}
|
||||
@@ -382,15 +773,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
|
||||
}
|
||||
|
||||
function collectCallIds(
|
||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
||||
turns: Map<number, TurnBucket>,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const entry of turns.values()) {
|
||||
for (const laid of entry.message) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
for (const list of entry.steps.values()) {
|
||||
for (const laid of list) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
}
|
||||
@@ -433,12 +821,27 @@ function expandSubCalls(
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'subtool',
|
||||
callId: sub.callId,
|
||||
text: settled
|
||||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
||||
: summarizeCall(sub.name, sub.argsRaw),
|
||||
...(settled
|
||||
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
||||
: { inputDetail: sub.argsRaw }),
|
||||
...(settled
|
||||
? {
|
||||
outputDetail: detailResult(sub),
|
||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(sub),
|
||||
isError: sub.isError,
|
||||
}
|
||||
: {}),
|
||||
// PR3's start/settle pair carries per-sub-call wall time; a running
|
||||
// (unsettled) or pre-pair log entry shows the em dash.
|
||||
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
|
||||
startedAt: settled
|
||||
? finiteTime(sub.callTime)
|
||||
: finiteTime(sub.time),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -448,8 +851,7 @@ function expandSubCalls(
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
||||
if (args === '') return name
|
||||
const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args
|
||||
return `${name} · ${clipped}`
|
||||
return `${name} · ${args}`
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
@@ -461,7 +863,40 @@ function summarizeResult(node: ToolResultNode): string {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
return 'No output'
|
||||
}
|
||||
|
||||
function detailResult(node: ToolResultNode): string {
|
||||
if (node.isError) {
|
||||
return node.error === undefined
|
||||
? 'error'
|
||||
: `${node.error.name}: ${node.error.code}`
|
||||
}
|
||||
const text = node.content
|
||||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
if (
|
||||
node.content.length === 0
|
||||
|| node.content.every(block =>
|
||||
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
|
||||
) return 'No output'
|
||||
return JSON.stringify(node.content, null, 2)
|
||||
}
|
||||
|
||||
function detailContent(content: readonly { type: string; text?: string }[]): string {
|
||||
return content
|
||||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text ?? '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
|
||||
return content
|
||||
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
|
||||
.map(block => block.text ?? '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Rough per-turn span derivation shared by the two placeholder views and the
|
||||
* header stats bar. P-I ships no timing data, so a span's weight is its node
|
||||
* count, not wall time (deviation ledger #3 — real spans land in P-III).
|
||||
*/
|
||||
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
|
||||
export interface SubSpanLane {
|
||||
callId: string
|
||||
name: string
|
||||
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
|
||||
durationMs: number | null
|
||||
/**
|
||||
* Timing provenance: `measured` = start/settle pair observed; `running` =
|
||||
* start seen, settle pending; `unknown` = settle-only replay window (the
|
||||
* start fell outside), so no duration claim is possible.
|
||||
*/
|
||||
timing: 'measured' | 'running' | 'unknown'
|
||||
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
|
||||
offsetFraction: number
|
||||
/** Width as a fraction of the window (running lanes extend to the window end). */
|
||||
widthFraction: number
|
||||
}
|
||||
|
||||
/** One turn's worth of activity, folded from the snapshot node window. */
|
||||
export interface TurnSpan {
|
||||
turn: number
|
||||
/** Assistant step messages inside the turn. */
|
||||
steps: number
|
||||
/** Tool results inside the turn (running calls are not folded in P-I). */
|
||||
calls: number
|
||||
/** Total nodes attributed to the turn (span weight stand-in). */
|
||||
nodes: number
|
||||
}
|
||||
|
||||
/** Aggregate totals for the header stats bar. */
|
||||
export interface SpanStats {
|
||||
turns: number
|
||||
steps: number
|
||||
calls: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn
|
||||
* number; user/steering/context/tool nodes attach to the turn last seen in
|
||||
* sequence order (turn 0 collects the pre-assistant prologue).
|
||||
* @param nodes - snapshot nodes in surface order.
|
||||
* @returns spans ordered by first appearance.
|
||||
*/
|
||||
export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] {
|
||||
const spans = new Map<number, TurnSpan>()
|
||||
let currentTurn = 0
|
||||
const spanFor = (turn: number): TurnSpan => {
|
||||
let span = spans.get(turn)
|
||||
if (span === undefined) {
|
||||
span = { turn, steps: 0, calls: 0, nodes: 0 }
|
||||
spans.set(turn, span)
|
||||
}
|
||||
return span
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (hasTurn(node)) currentTurn = node.turn
|
||||
const span = spanFor(currentTurn)
|
||||
span.nodes += 1
|
||||
if (node.kind === 'assistant') span.steps += 1
|
||||
if (node.kind === 'tool-result') span.calls += 1
|
||||
}
|
||||
return [...spans.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate spans into the header totals.
|
||||
* @param spans - deriveSpans product.
|
||||
* @returns turn/step/call totals.
|
||||
*/
|
||||
export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
|
||||
let steps = 0
|
||||
let calls = 0
|
||||
for (const span of spans) {
|
||||
steps += span.steps
|
||||
calls += span.calls
|
||||
}
|
||||
return { turns: spans.length, steps, calls }
|
||||
}
|
||||
|
||||
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
|
||||
return node.kind === 'assistant' || node.kind === 'steering'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
|
||||
* lane's offset/width scale against its parent turn's dispatch window (first
|
||||
* start → last settle). Running (unsettled) lanes extend to the window end
|
||||
* with a null duration.
|
||||
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
|
||||
* @param codeDispatches - the snapshot's dispatch index.
|
||||
* @returns lanes keyed by turn, in start order.
|
||||
*/
|
||||
export function deriveSubSpans(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
codeDispatches: ConversationSnapshot['codeDispatches'],
|
||||
): ReadonlyMap<number, readonly SubSpanLane[]> {
|
||||
const out = new Map<number, SubSpanLane[]>()
|
||||
if (codeDispatches.size === 0) return out
|
||||
const turnByCall = new Map<string, number>()
|
||||
let currentTurn = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
|
||||
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
|
||||
}
|
||||
for (const [parent, subs] of codeDispatches) {
|
||||
if (subs.length === 0) continue
|
||||
const turn = turnByCall.get(parent) ?? currentTurn
|
||||
// A settle-only entry (callTime null: its start fell outside the replay
|
||||
// window) anchors the window by its settle time — a real observation —
|
||||
// but must never masquerade as a measured zero-duration span.
|
||||
const starts: number[] = []
|
||||
const ends: number[] = []
|
||||
for (const sub of subs) {
|
||||
const settled = 'kind' in sub
|
||||
const start = settled ? sub.callTime ?? sub.time : sub.time
|
||||
starts.push(start)
|
||||
ends.push(settled ? sub.time : start)
|
||||
}
|
||||
const windowStart = Math.min(...starts)
|
||||
const windowEnd = Math.max(...ends, windowStart + 1)
|
||||
const windowSpan = windowEnd - windowStart
|
||||
const lanes: SubSpanLane[] = subs.map((sub, i) => {
|
||||
const settled = 'kind' in sub
|
||||
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
|
||||
const start = starts[i] ?? windowStart
|
||||
const end = settled ? sub.time : windowEnd
|
||||
return {
|
||||
callId: sub.callId,
|
||||
name: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
|
||||
timing,
|
||||
offsetFraction: (start - windowStart) / windowSpan,
|
||||
widthFraction: Math.max((end - start) / windowSpan, 0.02),
|
||||
}
|
||||
})
|
||||
const existing = out.get(turn) ?? []
|
||||
out.set(turn, [...existing, ...lanes])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/** Operation-sequence and recorded-time projections for the trajectory overview. */
|
||||
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||
|
||||
/** Horizontal projection used by the trajectory timeline. */
|
||||
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
|
||||
|
||||
/** Inclusive selection in the active timeline projection's domain. */
|
||||
export interface TrajectoryTimeRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** One ledger record projected into the active timeline domain. */
|
||||
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
label: string
|
||||
lane: number
|
||||
}
|
||||
|
||||
/** One turn boundary in the active timeline domain. */
|
||||
export interface TrajectoryTimelineTurnBoundary {
|
||||
turn: number
|
||||
time: number
|
||||
}
|
||||
|
||||
/** Full-domain model used by the overview. */
|
||||
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
||||
spans: readonly TrajectoryTimelineSpan[]
|
||||
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timeline duration with a compact unit.
|
||||
* @param milliseconds - Non-negative duration in milliseconds.
|
||||
* @returns Millisecond or second label.
|
||||
*/
|
||||
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`
|
||||
}
|
||||
|
||||
function laneFor(kind: TrajectoryCellKind): number {
|
||||
if (kind === 'tool' || kind === 'subtool') return 2
|
||||
if (kind === 'message' || kind === 'compacted') return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function finite(value: number | null | undefined): value is number {
|
||||
return value !== null && value !== undefined && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
|
||||
if (!finite(cell.startedAt)) return null
|
||||
const durationMs = finite(cell.timeSeconds)
|
||||
? Math.max(0, cell.timeSeconds * 1_000)
|
||||
: 0
|
||||
return { start: cell.startedAt, end: cell.startedAt + durationMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project every visible record into a stable three-lane timeline.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Timeline model, or `null` when no record is visible.
|
||||
*/
|
||||
export function deriveTrajectoryTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
mode: TrajectoryTimelineMode = 'sequence',
|
||||
): TrajectoryTimelineModel | null {
|
||||
if (mode !== 'sequence') {
|
||||
return deriveTimedTimeline(
|
||||
turns,
|
||||
mode === 'duration' || mode === 'actual',
|
||||
mode === 'duration',
|
||||
)
|
||||
}
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
const cells = turn.groups.flatMap(group =>
|
||||
group.cells.filter(cell => cell.requestOnly !== true),
|
||||
)
|
||||
if (cells.length === 0) continue
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: spans.length,
|
||||
})
|
||||
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
|
||||
start: spans.length + offset,
|
||||
end: spans.length + offset + 1,
|
||||
index: cell.index,
|
||||
kind: cell.kind,
|
||||
label: cell.text,
|
||||
lane: laneFor(cell.kind),
|
||||
})))
|
||||
}
|
||||
|
||||
if (spans.length === 0) return null
|
||||
return {
|
||||
start: 0,
|
||||
end: spans.length,
|
||||
spans,
|
||||
turnBoundaries,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTimedTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
actualDuration: boolean,
|
||||
removeUserIdle: boolean,
|
||||
): TrajectoryTimelineModel | null {
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
let removedUserIdle = 0
|
||||
let previousTurnEnd: number | null = null
|
||||
|
||||
for (const turn of turns) {
|
||||
const rawSpans = turn.groups.flatMap(group =>
|
||||
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
||||
if (cell.requestOnly === true) return []
|
||||
const range = cellRange(cell)
|
||||
return range === null
|
||||
? []
|
||||
: [{
|
||||
...range,
|
||||
index: cell.index,
|
||||
kind: cell.kind,
|
||||
label: cell.text,
|
||||
lane: laneFor(cell.kind),
|
||||
}]
|
||||
}),
|
||||
)
|
||||
if (rawSpans.length === 0) continue
|
||||
|
||||
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)
|
||||
}
|
||||
spans.push(...rawSpans.map(span => ({
|
||||
...span,
|
||||
start: span.start - removedUserIdle,
|
||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
||||
})))
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: turnStart - removedUserIdle,
|
||||
})
|
||||
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)),
|
||||
spans,
|
||||
turnBoundaries,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify records active at any point inside an inclusive selected interval.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param range - Selected interval in the active projection.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Record indexes inside the focus interval.
|
||||
*/
|
||||
export function trajectoryTimelineFocusIndexes(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
range: TrajectoryTimeRange,
|
||||
mode: TrajectoryTimelineMode = 'sequence',
|
||||
): ReadonlySet<number> {
|
||||
const model = deriveTrajectoryTimeline(turns, mode)
|
||||
return new Set(
|
||||
model?.spans
|
||||
.filter(span => span.start <= range.end && span.end >= range.start)
|
||||
.map(span => span.index),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Shared trajectory record data and formatting contracts. */
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Closed set of trajectory record kinds. */
|
||||
export type TrajectoryCellKind =
|
||||
| 'system'
|
||||
| 'user'
|
||||
| 'context'
|
||||
| 'compacted'
|
||||
| 'message'
|
||||
| 'tool'
|
||||
| 'subtool'
|
||||
|
||||
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
|
||||
export interface AssistantMetricDetail {
|
||||
timingRecorded: boolean
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
completedTime: number | null
|
||||
usageProvided: boolean
|
||||
outputTokens: number | null
|
||||
}
|
||||
|
||||
/** One source content block preserved in model order for the details panel. */
|
||||
export interface TrajectorySourceBlock {
|
||||
type: string
|
||||
content: string
|
||||
imageSrc?: string
|
||||
imageAlt?: string
|
||||
callId?: string
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
/** Data and optional presentation attributes for one trajectory record. */
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based record index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/** Whether this user record opens a new model turn. */
|
||||
opensTurn?: boolean
|
||||
/** Source session-event seq for cross-record navigation. */
|
||||
sourceSeq?: number
|
||||
/** Producer provenance from a user-role message or context injection. */
|
||||
messageSource?: unknown
|
||||
/** Producer-owned model-hidden metadata carried beside the message source. */
|
||||
/** A separator-only anchor for an auxiliary request with no visible record. */
|
||||
requestOnly?: boolean
|
||||
/** Full request/message content for the details panel. */
|
||||
inputDetail?: string
|
||||
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
|
||||
promptDetail?: ConversationPromptSnapshot
|
||||
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
|
||||
previousPromptDetail?: ConversationPromptSnapshot
|
||||
/** Full assistant/tool result content for the details panel. */
|
||||
outputDetail?: string
|
||||
/** Full assistant reasoning content for the details panel. */
|
||||
thinkingDetail?: string
|
||||
/** Original message blocks in source order for the details panel. */
|
||||
sourceBlocks?: readonly TrajectorySourceBlock[]
|
||||
/** Original tool result blocks in source order for the details panel. */
|
||||
outputBlocks?: readonly TrajectorySourceBlock[]
|
||||
/** Call-time model-visible tool schema for the details panel. */
|
||||
schemaDetail?: string
|
||||
/** Assistant-only timing and token facts for the details panel. */
|
||||
assistantMetrics?: AssistantMetricDetail
|
||||
/** Tool-only result summary paired with the call in the same record. */
|
||||
result?: string
|
||||
/** Tool call id used to link message source blocks to tool records. */
|
||||
callId?: string
|
||||
/** Tool-only result failure state. */
|
||||
isError?: boolean
|
||||
/** Own duration in seconds, or `null` when no duration is known. */
|
||||
timeSeconds: number | null
|
||||
/** Unix epoch milliseconds when this operation actually started, when known. */
|
||||
startedAt?: number | null
|
||||
/** Message-only prompt token count. */
|
||||
input?: number
|
||||
/** Message-only input tokens served from a provider cache. */
|
||||
cacheRead?: number
|
||||
/** Message-only input tokens written into a provider cache. */
|
||||
cacheWrite?: number
|
||||
/** Message-only completion token count. */
|
||||
output?: number
|
||||
/** Message-only reasoning token count. */
|
||||
think?: number
|
||||
/** Whether the legacy standalone cell renders its selection treatment. */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column.
|
||||
* @param seconds - Duration seconds, or `null` when absent.
|
||||
* @returns `—` when unknown, otherwise a seconds 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`
|
||||
}
|
||||
@@ -1,82 +1,22 @@
|
||||
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
|
||||
* cell content width is capped on the turn body (max 880). */
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger. */
|
||||
.root {
|
||||
overflow-y: auto;
|
||||
--dsh-trajectory-toolbar-height: 32px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Waterfall placeholder rows (shared module). */
|
||||
.row {
|
||||
.ledger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.turnTag {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-bg-skeleton);
|
||||
}
|
||||
|
||||
.barCalls {
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
|
||||
offset/width scaled to the dispatch window (real wall time). A running
|
||||
lane pulses via reduced opacity until its settle arrives. */
|
||||
.subRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subTag {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.barSub {
|
||||
height: 8px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.barSub[data-timing='running'] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
|
||||
.barSub[data-timing='unknown'] {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure-consumer plugin — it emits no cordis events
|
||||
* and owns no mutable cross-plugin state; both view-slot registrations are
|
||||
* plain effects whose disposal the slot ledger's own specs and this
|
||||
* and owns no mutable cross-plugin state; its view-slot registration is a
|
||||
* plain effect whose disposal the slot ledger's own specs and this
|
||||
* package's behavior specs observe directly.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user