Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-31 01:12:39 +08:00
36 changed files with 1270 additions and 150 deletions

View File

@@ -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/architecture/2026-07-30-command-row-copy-contract.md
2026-07-30-command-row-copy-contract.md: f6d5199389b3907780c501894e2861e6add85e77
2026-07-30-command-row-copy-contract.zh.md: 4afaf31640c07e88765060681739f262f322769e

View File

@@ -0,0 +1,35 @@
# Agent Note: Command row copy is split between the row and the handler
Status: implemented
English | [中文](2026-07-30-command-row-copy-contract.zh.md)
## Problem
The web command row renders `title · summary` from one logged [command lifecycle pair](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md): the title was the dispatched line rebuilt from `command/run` (`/permission workspace-write`) and the summary was `command/done`'s verbatim `text` (`Permission preset: workspace-write.`). Both halves were written without knowing about the other, so the row said the command name twice and its argument twice — the single worst case being the row a user gets for every Access-chip pick.
## Decision
The row's two halves have disjoint jobs, and each side is written to its own half alone.
The row title is the bare command name — no `/`, no arguments. The `/` belongs to the composer's input grammar, not to a settled record, and the argument is not the row's to report: the summary already says what the command did. `GenericCommandCard` keeps the `命令` fallback for a cross-window node whose `command/run` page fell out of the client's window.
A command handler's settlement `text` therefore never labels its value with the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write`, bare `current preset workspace-write (available: …)`, and for a bad argument `unknown preset "bogus" (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies.
The rule bans the *label*, not the vocabulary. `Permission preset: workspace-write.` lost because `Permission preset:` is a caption for a value whose caption is already the title. A domain noun that happens to contain the command's name is not a caption and stays: `/plan` keeps `Plan mode off.` and `Plan mode on. Use /plan off to leave.` (`plan · Plan mode off.` names the mode, and the tail is an instruction, not an echo), and `/goal` keeps `Goal cleared.`. A handler that finds itself writing `<Command> <noun>:` in front of its own value is the case this rule catches.
The log is unchanged: `command/run` keeps the structured `name`/`args` split, so a richer registered command row can still render arguments from the same node without a second data channel.
## Alternatives considered
**Keep the dispatched line as the title and only shorten the settlement text.** The argument would still appear on both sides of the separator (`permission workspace-write · preset workspace-write`), which is the repetition complained about.
**Drop the settlement text from the collapsed row instead of the arguments.** It inverts the row's value: the outcome is what a durable record is for, and an error text would then have nowhere to land.
**Have the row strip a leading command name from the settlement text.** Presentation would silently rewrite handler-authored text, and every handler that phrased its outcome differently would defeat the heuristic.
**Ban the command's name from its settlement text outright, rewriting `/plan` and `/goal` to match.** The broader ban costs more than it buys: `Plan mode off.` and `Goal cleared.` are the clearest sentences those outcomes have, in the row and as standalone TUI notices both, and the shortenings that satisfy a name ban (`off.`, `cleared.`) read as fragments. Captions are the redundancy worth removing.
## Consequences
Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-caption rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host.

View File

@@ -0,0 +1,35 @@
# Agent Note: Command row copy is split between the row and the handler
Status: implemented
[English](2026-07-30-command-row-copy-contract.md) | 中文
## Problem
Web 命令行由一对落库的[命令生命周期事件](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)渲染出 `标题 · 摘要`:标题是由 `command/run` 重建的分派命令行(`/permission workspace-write`),摘要是 `command/done` 的原样 `text``Permission preset: workspace-write.`)。两半各自成文、互不知情,于是一行里命令名出现两次、参数也出现两次——最糟的一例正是用户每次用 Access chip 切换权限时得到的那一行。
## Decision
命令行两半的职责互不重叠,各自只按自己那一半来写。
行标题就是裸命令名——没有 `/`,也没有参数。`/` 属于编辑器的输入语法,不属于一条已落定的记录;参数也不该由这一行来报告:摘要已经说清了这条命令做了什么。对于 `command/run` 那一页已滑出客户端窗口的跨窗口节点,`GenericCommandCard` 仍保留 `命令` 兜底标题。
因此,命令 handler 的落定 `text` 绝不用命令自身的名字给自己的值加标签——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`,参数非法时返回 `unknown preset "bogus" (available: …)`。作为一行读是 `permission · preset workspace-write`作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。
这条规则禁的是*标签*,不是用词。`Permission preset: workspace-write.` 之所以出局,是因为 `Permission preset:` 是给一个值加的题头,而这个题头正是标题本身。恰好含有命令名的领域名词不是题头,因此保留:`/plan` 仍返回 `Plan mode off.``Plan mode on. Use /plan off to leave.``plan · Plan mode off.` 说的是那个模式,句尾是一条指引,不是回声),`/goal` 仍返回 `Goal cleared.`。真正被这条规则拦下的,是 handler 在自己的值前面写出 `<命令名> <名词>` 的那一类。
日志本身未变:`command/run` 保留结构化的 `name``args` 拆分,因此更丰富的已注册命令行仍可从同一个节点渲染参数,无需第二条数据通道。
## Alternatives considered
**保留分派命令行作标题,只缩短落定文案。** 参数仍会出现在分隔点两侧(`permission workspace-write · preset workspace-write`),而这正是被指出的重复。
**从折叠行中去掉落定文案,而不是去掉参数。** 这颠倒了这一行的价值:持久记录存在的意义就是结果,而错误文案将无处落脚。
**由这一行从落定文案里剥掉开头的命令名。** 呈现层会悄悄改写 handler 写就的文案,而任何换一种措辞表达结果的 handler 都会让这套启发式失效。
**彻底禁止命令名出现在自己的落定文案里,并把 `/plan`、`/goal` 一并改写。** 这种更宽的禁令代价大于收益:无论在行上还是作为独立的 TUI 通知,`Plan mode off.``Goal cleared.` 都是这些结果最清楚的句子,而满足"禁名字"所需的缩写(`off.``cleared.`)读起来只是残句。值得去掉的冗余是题头。
## Consequences
每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不加题头"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。

View File

@@ -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-30-sidebar-resize-without-visible-pill.md
2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e
2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c

View File

@@ -0,0 +1,25 @@
# Agent Note: Sidebar resize without a visible pill
Status: implemented
English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md)
## Problem
The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful.
## Decision
AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill.
The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live.
## Alternatives considered
**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction.
**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary.
## Consequences
The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill.

View File

@@ -0,0 +1,25 @@
# Agent Note: 侧边栏缩放不显示胶囊
Status: implemented
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
## 问题
AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
## 决策
AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
## 曾考虑的替代方案
**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
## 后果
侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。

View File

@@ -3,15 +3,19 @@
// unselected states, and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise<number> {
})
}
/** First AppFrame grid track in CSS pixels. */
async function sidebarTrack(page: Page): Promise<number> {
return await appFrame(page).evaluate((element) => {
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
return Number.parseFloat(tracks[0] ?? 'NaN')
})
}
/** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
/** Render the two boundary affordances without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise<string> {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({
side: element.getAttribute('data-side'),
cursor: getComputedStyle(element).cursor,
pillGenerated: getComputedStyle(element, '::after').content !== 'none',
})))
return [
'# AppFrame drag handles',
'',
...handles.flatMap(handle => [
`## ${handle.side}`,
'',
'- hit strip present: true',
`- cursor: ${handle.cursor}`,
`- pill generated: ${String(handle.pillGenerated)}`,
'',
]),
].join('\n').trimEnd()
}
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -65,6 +99,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
const sidebarBefore = await sidebarTrack(page)
const sidebarHandle = page.locator('[data-side="sidebar"]')
const sidebarBox = await sidebarHandle.boundingBox()
expect(sidebarBox).not.toBeNull()
const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
await page.mouse.move(dragStartX, sidebarBox!.y + 200)
await page.mouse.down()
await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
await page.mouse.up()
await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
@@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
}, 90_000)
})

View File

@@ -3,7 +3,9 @@
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client fold of historical
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The seed is a recorded fixture under the
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
@@ -24,6 +26,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
// The command-row golden: the same conversation after one /permission switch,
// which is the only surface that shows a settled command row's copy.
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'seeded-history-web-e2e'
@@ -225,11 +230,33 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset workspace-write`,
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' })
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
// No replay fixture was installed and the llm seam is open — any stray
// stream would have failed the turn loudly. Cleanliness pins the wire.
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,7 @@
# AppFrame drag handles
## sidebar
- hit strip present: true
- cursor: col-resize
- pill generated: false

View File

@@ -11,7 +11,7 @@
- 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 "Turn 1 USER": 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"':
@@ -33,7 +33,7 @@
- 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 "Turn 2 USER": 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"':

View File

@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- img
- text: "/plan Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- button "复制":
- img
- button "在新对话中分支":

View File

@@ -0,0 +1,49 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- 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
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- img
- text: Read
- button "a.txt"
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button "上下文注入":
- img
- img
- text: 上下文注入
- img
- text: permission preset workspace-write
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -1480,14 +1480,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } })
}
return ok(request, { matched: true as const, commandId })
}

View File

@@ -1,12 +1,14 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
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'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
}
}
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
void this.loadForConsumers()
}
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
if (generation === this.generation) this.publishDirtyNow()
}
}
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry]
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**

View File

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } 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'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -135,7 +135,7 @@ describe('live event path', () => {
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')

View File

@@ -1,6 +1,6 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// GenericToolCard rendering the command name and its settlement text.
// Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
@@ -20,10 +20,11 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? '命令'
return (
<ToolRow
variant="others"

View File

@@ -467,10 +467,14 @@ describe('ChatView', () => {
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
// (the settlement text already says what the command did).
const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
const view = render(<settled.ChatView {...settled.props} />)
expect(view.getByText('/plan')).toBeTruthy()
expect(view.getByText('plan')).toBeTruthy()
expect(view.queryByText('/plan')).toBeNull()
expect(view.queryByText('/plan now')).toBeNull()
expect(view.getByText('已进入 plan mode')).toBeTruthy()
// Error outcome flips the row state; a text-less error gets the default copy.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。

View File

@@ -49,9 +49,8 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. Details
adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
.handle {
position: absolute;
top: 0;
@@ -76,7 +75,7 @@
}
}
.handle::after {
.handle[data-side='details']::after {
content: '';
position: absolute;
top: 50%;
@@ -88,23 +87,22 @@
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
/* Hover affordance: the details pill hides until the pointer is over its
column, the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}

View File

@@ -16,6 +16,7 @@
flex: 1;
min-width: 0;
overflow: auto;
container: trajectory-table / inline-size;
}
.table {
@@ -235,6 +236,18 @@
width: 3px;
}
.table tbody tr[data-error='true'] .turnRail {
background: color-mix(
in srgb,
var(--dsw-alias-state-error-primary) 22%,
var(--dsw-alias-bg-layer-1)
);
}
.table tbody tr[data-error='true'] .selectionRail {
background: var(--dsw-alias-state-error-primary);
}
.table tbody tr[data-turn-start='true'] td {
position: relative;
overflow: visible;
@@ -279,6 +292,10 @@
white-space: nowrap;
}
.turnLabelCompact {
display: none;
}
.turnLabelActive {
color: color-mix(
in srgb,
@@ -325,11 +342,66 @@
user-select: none;
}
.kindTagIcon {
display: none;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
.kindTagLabel {
display: inline;
}
.table .kindSlot .message {
justify-content: center;
width: 100%;
}
@container trajectory-table (max-width: 620px) {
.eventColumn {
width: 50px;
}
.event {
padding-right: 3px !important;
padding-left: 28px !important;
}
.requestBoundaryControl {
left: 6px;
}
.kindSlot {
width: 19px;
}
.kindTag,
.table .kindSlot .message {
justify-content: center;
width: 19px;
padding-right: 0;
padding-left: 0;
}
.kindTagIcon {
display: inline-flex;
}
.kindTagLabel {
display: none;
}
.turnLabelFull {
display: none;
}
.turnLabelCompact {
display: inline;
}
}
.user {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
@@ -576,6 +648,28 @@
color: var(--dsw-alias-state-error-primary);
}
.overview dd.error {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload .resultBlockText {
color: inherit;
}
.details .jsonPayload.errorPayload,
.details .jsonPreview.errorPayload {
--json-tree-property: var(--dsw-alias-state-error-primary);
--json-tree-string: var(--dsw-alias-state-error-primary);
--json-tree-number: var(--dsw-alias-state-error-primary);
--json-tree-keyword: var(--dsw-alias-state-error-primary);
--json-tree-punctuation: var(--dsw-alias-state-error-primary);
--json-tree-icon: var(--dsw-alias-state-error-primary);
}
.details {
position: relative;
display: flex;

View File

@@ -1,9 +1,15 @@
/** Turn-aware trajectory event ledger with a local record inspector. */
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
IconChevronRightOutline14,
IconSettingsOutline16,
IconSparkle16,
IconUserOutline16,
JsonTree,
MarkdownText,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
@@ -13,7 +19,7 @@ import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -26,6 +32,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'SUBTOOL',
}
function ToolWrenchIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="wrench"
aria-hidden="true"
>
<path d="M14 3.3a3.8 3.8 0 0 1-4.8 4.8l-5.1 5.1a1.6 1.6 0 1 1-2.3-2.3l5.1-5.1A3.8 3.8 0 0 1 11.7 1l-2.3 2.3 2.3 2.3L14 3.3Z" />
</svg>
)
}
function InformationIcon(): ReactNode {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
data-role-icon="information"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6.7" />
<circle cx="8" cy="5.5" r=".85" fill="currentColor" stroke="none" />
<path d="M8 7.75v3.4" strokeWidth="1.8" />
</svg>
)
}
function CompactedIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="compacted"
aria-hidden="true"
>
<path d="m2.5 2.5 3.75 3.75M3 6.25h3.25V3" />
<path d="m13.5 2.5-3.75 3.75M13 6.25H9.75V3" />
<path d="m2.5 13.5 3.75-3.75M3 9.75h3.25V13" />
<path d="m13.5 13.5-3.75-3.75M13 9.75H9.75V13" />
</svg>
)
}
const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
system: <IconSettingsOutline16 size={13} />,
user: <IconUserOutline16 size={13} />,
context: <InformationIcon />,
compacted: <CompactedIcon />,
message: <IconSparkle16 size={13} />,
tool: <ToolWrenchIcon />,
subtool: <ToolWrenchIcon />,
}
interface TableRecord {
turn: number
group: string
@@ -225,6 +302,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
@@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
: cell.kind === 'message'
? cell.outputDetail ?? cell.thinkingDetail
: undefined
if (!markdown) return cell.text
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
}
function toolCallTextParts(
@@ -1043,13 +1122,20 @@ function SystemPromptDiff({
function ToolOutputBlocks({
blocks,
error,
preview,
}: {
blocks: readonly TrajectorySourceBlock[]
error: boolean
preview: boolean
}) {
return (
<div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
<div className={[
css.resultBlocks,
preview ? css.resultBlocksPreview : undefined,
error ? css.errorPayload : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{blocks.map((block, index) => (
block.imageSrc !== undefined
? <PanelImage block={block} preview={preview} key={index} />
@@ -1222,6 +1308,9 @@ function RecordPayload({
? 'No payload captured'
: 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p>
const error = direction === 'output' && record.cell.isError === true
const payloadClass = preview ? css.jsonPreview : css.jsonPayload
const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass
const json = parseJsonContainer(value)
const singleTextResult = direction === 'output'
@@ -1232,7 +1321,7 @@ function RecordPayload({
<JsonTree
data={json}
label="Result JSON"
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1245,6 +1334,7 @@ function RecordPayload({
return (
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
error={error}
preview={preview}
/>
)
@@ -1258,7 +1348,11 @@ function RecordPayload({
)
if (markdown) {
return (
<div className={preview ? css.markdownPreview : css.markdownPayload}>
<div className={[
preview ? css.markdownPreview : css.markdownPayload,
error ? css.errorPayload : undefined,
].filter((className): className is string => className !== undefined).join(' ')}
>
<MarkdownText text={value} />
</div>
)
@@ -1268,7 +1362,7 @@ function RecordPayload({
<JsonTree
data={json}
label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1276,7 +1370,7 @@ function RecordPayload({
<pre className={[
css.payload,
preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined,
error ? css.errorPayload : undefined,
value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(true)
const [thinkingExpanded, setThinkingExpanded] = useState(false)
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
onClearSelection?.()
}
const selectRecord = (index: number) => {
const selectRecord = useCallback((index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
}
}, [allRecords, onRecordSelect])
useEffect(() => {
if (
recordSelection === null
|| appliedRecordSelection.current === recordSelection
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const selectRequest = (
request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={`Turn ${record.turn}`}
>
Turn {record.turn}
<span className={css.turnLabelFull} aria-hidden="true">
Turn {record.turn}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</span>
)}
<div className={css.eventInner}>
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</Tooltip>
</span>
)}
</div>
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
{statusLabel(selectedRequestState)}
</dd>
</div>
{selectedRequestInfo?.purpose === 'compaction' && (
<div>
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({
{selectedRequestInfo?.error !== undefined && (
<div>
<dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd>
<dd className={css.error}>{selectedRequestInfo.error}</dd>
</div>
)}
{selectedRequestInfo?.retry !== undefined && (
@@ -2122,7 +2243,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
<div>
<dt>Duration</dt>
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({
)}
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
{selected.cell.kind === 'message' && (
<TokenRows cell={selected.cell} />

View File

@@ -70,16 +70,29 @@
.lanes {
position: absolute;
z-index: 2;
inset: 7px 0;
top: 7px;
bottom: 7px;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
}
.turnBoundaries {
position: absolute;
z-index: 3;
inset: 0;
top: 0;
bottom: 0;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.lanes[data-animate-viewport='true'],
.turnBoundaries[data-animate-viewport='true'] {
transition: left 180ms ease-out;
}
}
.turnBoundary {
position: absolute;
top: 0;
@@ -133,6 +146,10 @@
);
}
.span[data-error='true'] {
background: var(--dsw-alias-state-error-primary);
}
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
@@ -142,6 +159,18 @@
opacity: 0.2;
}
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 80%,
transparent
);
}
.span[data-current='true'] {
z-index: 1;
opacity: 1;

View File

@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
interface FractionRange {
start: number
end: number
}
interface HoverPoint {
fraction: number
recordIndex: number | null
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
/** Select a directly clicked timeline block. */
onRecordSelect?: (index: number) => void
/** Bring the nearest record into view after clicking timeline whitespace. */
onRecordFocus?: (index: number) => void
}
@@ -41,11 +52,16 @@ 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))
function centeredRange(
center: number,
width: number,
minimum: number,
maximum: number,
): FractionRange {
const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
const start = Math.min(
Math.max(center - clampedWidth / 2, 0),
1 - clampedWidth,
Math.max(center - clampedWidth / 2, minimum),
maximum - clampedWidth,
)
return { start, end: start + clampedWidth }
}
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
onRecordSelect,
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)),
[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 dragRef = useRef<{
pointerId: number
anchorTime: number
anchorClientX: number
recordIndex: number | null
} | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
if (
model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}, [model, onRangeChange, range])
useEffect(() => {
if (model === null) return
setAnimateViewport(false)
setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end)
? null
: current)
}, [model])
useEffect(() => {
if (model === null || selectedIndex === null) return
const selectedSpan = model.spans.find(span => span.index === selectedIndex)
if (selectedSpan === undefined) return
setAnimateViewport(true)
setViewport((current) => {
if (current === null) return current
if (
selectedSpan.end > current.start
&& selectedSpan.start < current.end
) return current
const duration = Math.max(1, current.end - current.start)
const desiredStart = selectedSpan.end <= current.start
? selectedSpan.start
: selectedSpan.end - duration
const nextStart = Math.min(
Math.max(desiredStart, model.start),
Math.max(model.start, model.end - duration),
)
if (nextStart === current.start) return current
return { start: nextStart, end: nextStart + duration }
})
}, [model, selectedIndex])
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min(
fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const projectedDomainStyle = model === null
? undefined
: {
'--trajectory-domain-left':
`${-(domainStart - model.start) / domainDuration * 100}%`,
'--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`,
} as CSSProperties
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,
}
const draftFraction = model === null || draft === null
? null
: rangeFraction(draft, domainStart, domainDuration)
const visibleRange = draftFraction ?? committed
const activeRange = draft ?? range
if (model === null) {
return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
}
const minimumSelectionFraction = Math.min(
1,
fullDuration / domainDuration / model.spans.length,
const minimumSelectionDuration = Math.min(
domainDuration,
fullDuration / model.spans.length,
)
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
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 recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
const target = event.target instanceof HTMLElement ? event.target : null
const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
?.dataset.timelineRecordIndex
if (value === undefined) return null
const index = Number(value)
return Number.isFinite(index) ? index : null
}
const commit = (nextRange: TrajectoryTimeRange) => {
onRangeChange(nextRange)
}
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) }
const anchorTime = domainStart + anchor * domainDuration
const recordIndex = recordIndexAt(event)
setHover({ fraction: anchor, recordIndex })
dragRef.current = {
pointerId: event.pointerId,
anchorTime,
anchorClientX: event.clientX,
recordIndex,
}
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
setDraft({ start: anchorTime, end: anchorTime })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover(fraction)
setHover({ fraction, recordIndex: recordIndexAt(event) })
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fraction))
let nextDomainStart = domainStart
if (viewport !== null) {
const localX = event.clientX - rect.left
const edgeWidth = Math.min(
MAXIMUM_EDGE_PAN_PX,
Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION),
)
const direction = localX < edgeWidth
? -1
: localX > rect.width - edgeWidth ? 1 : 0
if (direction !== 0) {
const edgeDistance = direction < 0
? edgeWidth - localX
: localX - (rect.width - edgeWidth)
const strength = clampFraction(edgeDistance / edgeWidth)
const desiredStart = domainStart
+ direction * domainDuration * EDGE_PAN_STEP_FRACTION
* Math.max(0.2, strength)
nextDomainStart = Math.min(
Math.max(desiredStart, model.start),
model.end - domainDuration,
)
if (nextDomainStart !== domainStart) {
setAnimateViewport(false)
setViewport({
start: nextDomainStart,
end: nextDomainStart + domainDuration,
})
}
}
}
const pointTime = nextDomainStart + fraction * domainDuration
setDraft(orderedRange(drag.anchorTime, pointTime))
}
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)
const pointFraction = fractionAt(event)
const pointTime = domainStart + pointFraction * domainDuration
const selected = orderedRange(drag.anchorTime, pointTime)
setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
dragRef.current = null
setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction
const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
const clickedSpan = click && drag.recordIndex !== null
? model.spans.find(span => span.index === drag.recordIndex)
: undefined
if (clickedSpan !== undefined) {
onRangeChange(null)
onRecordSelect?.(clickedSpan.index)
return
}
const committedRange = selected.end - selected.start < minimumSelectionDuration
? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction,
minimumSelectionDuration,
model.start,
model.end,
)
: selected
commit(committedRange)
if (click) {
const timelinePoint = domainStart + selected.start * domainDuration
const timelinePoint = selected.start
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
setAnimateViewport(false)
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && draft === null && (
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
data-timeline-hover-line
aria-hidden="true"
style={{
'--trajectory-hover-left': `${hover * 100}%`,
'--trajectory-hover-left': `${hover.fraction * 100}%`,
} as CSSProperties}
/>
)}
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
/>
</>
)}
<div className={css.turnBoundaries} aria-hidden="true">
<div
className={css.turnBoundaries}
data-animate-viewport={animateViewport || undefined}
aria-hidden="true"
style={projectedDomainStyle}
>
{model.turnBoundaries
.slice(1)
.filter(boundary =>
@@ -326,24 +442,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
`${(boundary.time - model.start) / fullDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
<div
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.filter(span =>
span.index === selectedIndex
|| (span.end >= domainStart && span.start <= domainStart + domainDuration))
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}

View File

@@ -147,6 +147,9 @@ export function TrajectoryView({
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -12,6 +12,7 @@ import type {
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
@@ -66,6 +67,9 @@ interface TurnBucket {
groups: LaidGroup[]
}
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
@@ -126,6 +130,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
@@ -353,7 +358,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
@@ -770,12 +775,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
return map
}
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
const ids = new Set<string>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
for (const block of node.blocks) {
if (block.kind === 'tool-call') ids.add(block.callId)
}
}
return false
return ids
}
function collectCallIds(
@@ -855,7 +863,7 @@ function expandSubCalls(
}
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
const args = trajectoryPreviewText(argsRaw)
if (args === '') return name
return `${name} · ${args}`
}
@@ -913,5 +921,20 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
}
function summarizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
return trajectoryPreviewText(text)
}
/**
* Build a bounded one-line ledger preview without parsing the complete Markdown document.
* Full source remains on the cell for the inspector.
* @param text - Untrusted message, reasoning, payload, or result text.
* @returns A compact preview capped independently from the retained source.
*/
export function trajectoryPreviewText(text: string): string {
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
return source.length < text.length || preview.length < compact.length
? `${preview}`
: preview
}

View File

@@ -15,6 +15,7 @@ export interface TrajectoryTimeRange {
/** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
isError: boolean
kind: TrajectoryCellKind
label: string
lane: number
@@ -94,6 +95,7 @@ export function deriveTrajectoryTimeline(
start: spans.length + offset,
end: spans.length + offset + 1,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
@@ -129,6 +131,7 @@ function deriveTimedTimeline(
: [{
...range,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),

View File

@@ -176,6 +176,25 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
const nodes = [{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
blocks: [{ kind: 'reasoning', text: thinking }],
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
expect(message?.text.endsWith('…')).toBe(true)
expect(message?.text.length).toBeLessThanOrEqual(513)
expect(message?.thinkingDetail).toBe(thinking)
})
it('advances the duration cursor over context nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },

View File

@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'private chain…',
thinkingDetail: thinking,
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
expect(screen.queryByText(thinking)).toBeNull()
fireEvent.click(toggle)
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
})
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [
'<background-task-complete id="trajectory-ui-watch">',
@@ -144,8 +169,53 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy()
expect(screen.getByText('Failed').className).toContain('error')
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
const errorResult = screen.getByText('ToolError: non_zero_exit')
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
fireEvent.mouseLeave(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('uses information and compression glyphs for injected and compacted context', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Context',
cells: [
{ index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
{ index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
],
}],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(view.container.querySelector(
'[data-role-kind="context"] [data-role-icon="information"]',
)).toBeTruthy()
expect(view.container.querySelector(
'[data-role-kind="compacted"] [data-role-icon="compacted"]',
)).toBeTruthy()
})
it('keeps a compact turn label available for narrow layouts', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const turnLabel = screen.getByLabelText('Turn 1')
expect(turnLabel.textContent).toContain('Turn 1')
expect(turnLabel.textContent).toContain('#1')
})
it('renders a single-text JSON tool result as a JSON tree', () => {

View File

@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
@@ -314,6 +315,44 @@ describe('tab switching in ConversationRoot', () => {
.toBeNull()
})
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
const b = await bench()
const view = mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
const toolSpan = view.container.querySelector<HTMLElement>(
'[data-timeline-span="tool"]',
)
expect(toolSpan).not.toBeNull()
const recordIndex = toolSpan?.dataset.timelineRecordIndex
expect(recordIndex).toBeTruthy()
fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
fireEvent.pointerDown(toolSpan as HTMLElement, {
button: 0, clientX: 50, pointerId: 2,
})
fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
const selectedRow = view.container.querySelector<HTMLElement>(
`tr[data-record-index="${recordIndex}"]`,
)
expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([]))
mount(b.slots)
@@ -337,6 +376,97 @@ describe('timeline projection', () => {
],
}],
}] satisfies readonly TrajectoryTurnModel[]
const longTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 10 }, (_, index) => ({
index,
kind: 'message' as const,
text: `record ${index}`,
timeSeconds: 1,
})),
}],
}] satisfies readonly TrajectoryTurnModel[]
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={1}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
})
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={8}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
})
})
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
const onRangeChange = vi.fn()
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
for (let index = 0; index < 24; index++) {
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
}
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
| { start: number; end: number }
| undefined
expect(selectedRange).toBeDefined()
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
})
it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
@@ -344,15 +474,50 @@ describe('timeline projection', () => {
end: 3,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
index: 1, isError: false, kind: 'message', label: 'assistant',
lane: 1, start: 0, end: 1,
},
{
index: 2, isError: false, kind: 'tool', label: 'bash',
lane: 2, start: 1, end: 2,
},
{
index: 3, isError: false, kind: 'user', label: 'unknown',
lane: 0, start: 2, end: 3,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
],
turnBoundaries: [{ turn: 1, time: 0 }],
})
})
it('marks error records directly on timeline spans', () => {
const errorTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool' as const,
text: 'failed tool',
timeSeconds: 0.1,
isError: true,
}],
}],
}] satisfies readonly TrajectoryTurnModel[]
const view = render(
<TrajectoryTimeline
turns={errorTurns}
mode="sequence"
range={null}
onRangeChange={() => {}}
/>,
)
expect(view.container.querySelector(
'[data-timeline-span="tool"][data-error="true"]',
)).toBeTruthy()
})
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{

View File

@@ -211,16 +211,19 @@ export class PermissionService extends Service {
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
input: { hint: '<preset>' },
// No settlement text labels its value with this command's own name: a
// surface that renders `name · text` (the web command row) would
// otherwise read `permission · Permission preset: workspace-write.`
handler: ({ agent, rawInput }) => {
const name = rawInput.trim()
if (name === '') {
return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` }
return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` }
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
return { kind: 'success', text: `Permission preset: ${name}.` }
return { kind: 'success', text: `preset ${name}` }
},
})
})

View File

@@ -89,7 +89,7 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' })
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
const run = session.events.find(event => event.type === 'command/run')
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
@@ -101,7 +101,7 @@ describe('/permission command', () => {
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
expect(execution?.result).toEqual({
kind: 'success',
text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.',
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
})
@@ -110,7 +110,13 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
expect(execution?.result).toMatchObject({ kind: 'error' })
// The error text carries the same no-self-labelling rule as the success
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
// preset`, which the row's own title already says.
expect(execution?.result).toEqual({
kind: 'error',
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
})
})