diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml new file mode 100644 index 0000000000..330a279b35 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md new file mode 100644 index 0000000000..f6d5199389 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md @@ -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 ` :` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md new file mode 100644 index 0000000000..4afaf31640 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md @@ -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 上执行,它能无密钥地抵达一条真实的落定命令行。 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml new file mode 100644 index 0000000000..76604171be --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md new file mode 100644 index 0000000000..cc41898990 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md @@ -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. diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md new file mode 100644 index 0000000000..9f1f521df2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 侧边栏缩放不显示胶囊 + +Status: implemented + +[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文 + +## 问题 + +AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。 + +## 决策 + +AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。 + +布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。 + +## 曾考虑的替代方案 + +**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。 + +**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。 + +## 后果 + +侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 23a14ef445..8105519338 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -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 { }) } +/** First AppFrame grid track in CSS pixels. */ +async function sidebarTrack(page: Page): Promise { + 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 { + 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) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d6a78c9277..4d7825963a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -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 ` — 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']) }) }) diff --git a/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md new file mode 100644 index 0000000000..aa865c4ad4 --- /dev/null +++ b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md @@ -0,0 +1,7 @@ +# AppFrame drag handles + +## sidebar + +- hit strip present: true +- cursor: col-resize +- pill generated: false diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 0bdf87d67a..788b8a3233 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -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"': diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index aca0bc31bb..cb905c0b8b 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -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 "在新对话中分支": diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md new file mode 100644 index 0000000000..87ffd9fc55 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -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 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5f2b7ae7af..9ba3840ed6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 }) } diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 8de11b9da9..4f3e86be6a 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -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 { @@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace { private async doOpen(generation: number): Promise { 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 { 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' +} diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 189febf253..242232f3c1 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -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 } } /** diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.spec.ts index 25681ea4cc..c1df190c33 100644 --- a/packages/client/runtime/tests/partial.spec.ts +++ b/packages/client/runtime/tests/partial.spec.ts @@ -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"' })) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index 1375338e90..bcf25cd933 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -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 diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 875f687424..3bbdf4046a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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') diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..4b42a94c72 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -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 ( { 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() - 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. diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 870937c93c..aba4711a24 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e92958c9b..cb99023e6a 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -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. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 0fb3b1cd85..3681b45176 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -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 表层获取操作。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..ef6c70661d 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -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); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 3e0e96b838..c7b7948845 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -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; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..fb369da242 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -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 = { @@ -26,6 +32,77 @@ const KIND_LABEL: Record = { subtool: 'SUBTOOL', } +function ToolWrenchIcon(): ReactNode { + return ( + + ) +} + +function InformationIcon(): ReactNode { + return ( + + ) +} + +function CompactedIcon(): ReactNode { + return ( + + ) +} + +const KIND_ICON: Record = { + system: , + user: , + context: , + compacted: , + message: , + tool: , + subtool: , +} + 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 ( -
+
value !== undefined).join(' ')} + > {blocks.map((block, index) => ( block.imageSrc !== undefined ? @@ -1222,6 +1308,9 @@ function RecordPayload({ ? 'No payload captured' : 'No result captured' if (!value) return

{missing}

+ 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({ ) } @@ -1245,6 +1334,7 @@ function RecordPayload({ return ( ) @@ -1258,7 +1348,11 @@ function RecordPayload({ ) if (markdown) { return ( -
+
className !== undefined).join(' ')} + >
) @@ -1268,7 +1362,7 @@ function RecordPayload({ ) } @@ -1276,7 +1370,7 @@ function RecordPayload({
 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(null)
   const [selectedRequest, setSelectedRequest] = useState(null)
   const [activeTab, setActiveTab] = useState('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [thinkingExpanded, setThinkingExpanded] = useState(false)
   const [detailsWidth, setDetailsWidth] = useState(null)
   const [toolRequestOffset, setToolRequestOffset] = useState(null)
   const detailsResizeDrag = useRef(null)
+  const appliedRecordSelection = useRef(null)
   const tabHistory = useRef>(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}
+                        
+                        
                       
                     )}
                     
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({ - - {KIND_LABEL[record.cell.kind]} - + + + + + {KIND_LABEL[record.cell.kind]} + + + )}
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedRequestState)}
+
+ {statusLabel(selectedRequestState)} +
{selectedRequestInfo?.purpose === 'compaction' && (
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({ {selectedRequestInfo?.error !== undefined && (
Error
-
{selectedRequestInfo.error}
+
{selectedRequestInfo.error}
)} {selectedRequestInfo?.retry !== undefined && ( @@ -2122,7 +2243,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
Duration
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({ )}
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
{selected.cell.kind === 'message' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 734b2f3326..402387da48 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -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; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 8ad1afefc1..89e3b8bb2b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -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 | 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(null) - const [hover, setHover] = useState(null) + const dragRef = useRef<{ + pointerId: number + anchorTime: number + anchorClientX: number + recordIndex: number | null + } | null>(null) + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(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): 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): number | null => { + const target = event.target instanceof HTMLElement ? event.target : null + const value = target?.closest('[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) => { 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) => { 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) => { 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) => { 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 && (