mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
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/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
|
||||
@@ -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.
|
||||
@@ -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 上执行,它能无密钥地抵达一条真实的落定命令行。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 侧边栏缩放不显示胶囊
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
|
||||
|
||||
## 决策
|
||||
|
||||
AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
|
||||
|
||||
布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
|
||||
|
||||
**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
|
||||
|
||||
## 后果
|
||||
|
||||
侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# AppFrame drag handles
|
||||
|
||||
## sidebar
|
||||
|
||||
- hit strip present: true
|
||||
- cursor: col-resize
|
||||
- pill generated: false
|
||||
@@ -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"':
|
||||
|
||||
@@ -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 "在新对话中分支":
|
||||
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"' }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 表层获取操作。
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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}` }
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user