From fe505e1e91ce7dcd2fda1c3c3a45eaa92abb2b60 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:02:10 -0700 Subject: [PATCH 01/12] feat(web): a prose mention of a produced file opens it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatFileMentions service (provided by ui-deliverables beside its turn-tail entry, reached via ctx.get) resolves inline-code tokens in the closing message against the turn's produced locations: exact path or unique basename links, ambiguity and unknowns stay inert. MarkdownText gains the optional fileMentions seam — settled renders only, never inside anchors. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 6 + .../2026-08-07-web-inline-file-mentions.md | 28 +++ .../2026-08-07-web-inline-file-mentions.zh.md | 28 +++ apps/web/tests/produced-file-mentions.e2e.ts | 162 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 29 +++- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/contract/slots.ts | 32 ++++ .../ui-conversation/src/client/index.ts | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 41 +++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/ProducedFiles.tsx | 7 +- .../ui-deliverables/src/client/index.ts | 25 ++- .../src/client/turn-deliverables.ts | 43 +++++ .../tests/produced-files.spec.tsx | 47 ++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 +- .../src/markdown/MarkdownText.module.css | 20 +++ .../src/markdown/MarkdownText.tsx | 26 ++- .../ui-primitives/src/markdown/render.tsx | 51 +++++- .../tests/markdown-render-units.spec.tsx | 1 + .../ui-primitives/tests/markdown.spec.tsx | 43 +++++ tsconfig.host.json | 1 + 31 files changed, 584 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md create mode 100644 apps/web/tests/produced-file-mentions.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml new file mode 100644 index 0000000000..e8fe387234 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.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/feature/2026-08-07-web-inline-file-mentions.md +2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 +2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md new file mode 100644 index 0000000000..79c6bddd50 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -0,0 +1,28 @@ +# Agent Note: inline-code file mentions open the file they name + +Status: implemented + +English | [中文](2026-08-07-web-inline-file-mentions.zh.md) + +> Scope: linking inline-code tokens in the closing message's prose to the files the turn produced. Not in scope: recognizing paths in plain prose, linking files the turn did not produce, and mentions in streaming or mid-turn messages. + +## Problem + +The produced-files row lists a turn's output, but the closing message usually also *names* the file in prose — as inline code, like `` `deepseek-homepage.html` `` — and that mention was inert text. The reader's eye lands on the sentence first; the affordance sat one row below it. + +## Decision + +**A prose mention links only when it matches a produced file.** The [produced-files decision](2026-07-31-web-workspace-file-links.md) rejected linkifying the closing message because rendering must not depend on the model spelling a path recognizably; that holds. The row remains the authoritative, prose-independent account. This feature adds a second consumer of the same `locations` vocabulary: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path. A basename two paths share stays inert rather than guessing, and a token naming nothing the turn wrote stays inert — a mention link can never 404. + +**The renderer owns no vocabulary, and the provider is the deliverables plugin.** `MarkdownText` takes an optional `MarkdownFileMentions` resolver and consults it for inline-code tokens — after URL promotion, which wins, and never inside an anchor, where a button cannot nest. What names a file is decided behind the optional `chatFileMentions` service ui-conversation reaches via `ctx.get`: ui-deliverables provides it beside its turn-tail chain entry, so one cordis.yml line composes the row and the prose links in or out together, and ui-primitives gains no session concepts. Mentions apply to settled renders only — the streaming cache must not bake in handlers that could go stale, and the vocabulary is not final until the turn closes. The consumer memoizes the resolver on the closing seq rather than the growing transcript, so a settled message's cached parse survives stream appends. + +## Alternatives considered + +- **Path-shaped regex over all prose** — links `package.json` mentioned abstractly and examples that were never written; every false positive is a click that opens nothing or the wrong file. The vocabulary approach cannot produce a dead link. +- **Linking suffix matches (`out/index.html` mentioned as `index.html` in a subdirectory listing)** — deferred; exact path and unique basename cover the observed closing-message shapes, and a wider matcher can loosen later without breaking the seam. +- **Resolving in ui-primitives against a passed path list** — puts matching policy in the generic renderer, where other consumers would inherit it unasked. The resolver seam keeps policy with the owner. +- **Threading the vocabulary through the turn-tail chain** — the chain is a render dispatch below the message; mentions decorate markdown inside it, which only data reaching MarkdownText can do. The optional service is that data path, and its absence is the off state. + +## Consequences + +The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md new file mode 100644 index 0000000000..ddc54d3b43 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -0,0 +1,28 @@ +# Agent Note:行内代码文件提及可打开其命名的文件 + +Status: implemented + +[English](2026-08-07-web-inline-file-mentions.md) | 中文 + +> 范围:把收尾消息正文中的行内代码 token 链接到本轮产出的文件。不在范围内:识别普通正文中的路径、链接本轮未产出的文件,以及流式或轮次中途消息里的提及。 + +## 问题 + +产物行列出了一轮的输出,但收尾消息通常也会在正文里*点名*文件——以行内代码形式,如 `` `deepseek-homepage.html` ``——而这个提及是死文本。读者的视线先落在句子上;可点击的交互却在下面一行。 + +## 决定 + +**正文提及只在与产出文件对得上时才成为链接。**[产物行的决定](2026-07-31-web-workspace-file-links.md)否决过"把收尾消息链接化",理由是渲染不能依赖模型把路径写得可识别;这一点不变。产物行仍是权威的、不依赖正文的记录。本特性只是给同一份 `locations` 词表增加第二个消费者:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析。两条路径共享的 basename 保持死文本而不猜测,命名了本轮没写过的文件的 token 同样保持死文本——提及链接永远不会 404。 + +**渲染器不持有词表,提供方是 deliverables 插件。**`MarkdownText` 接受可选的 `MarkdownFileMentions` 解析器,对行内代码 token 询问它——URL 提升优先于解析器,且绝不在锚点内部(按钮不能嵌套在链接里)。什么算文件名的决定藏在 ui-conversation 经 `ctx.get` 触达的可选 `chatFileMentions` service 背后:ui-deliverables 在其 turn-tail chain 注册项旁提供该 service,因此 cordis.yml 中的一行同时把产物行和正文链接组合进来或去掉,ui-primitives 不引入任何会话概念。提及只作用于已定稿的渲染——流式缓存不能烘进可能过期的 handler,而且词表在轮次收尾前并不最终。消费方按收尾 seq 而非不断增长的 transcript 记忆化解析器,因此已定稿消息的缓存解析在流式追加中得以保留。 + +## 考虑过的替代方案 + +- **对全部正文跑路径形状的正则**——会把随口提到的 `package.json` 和从未写过的示例都链接上;每个误报都是一次打开空无或错误文件的点击。词表方案不可能产生死链。 +- **链接后缀匹配(子目录列表里把 `out/index.html` 写作 `index.html`)**——暂缓;精确路径加唯一 basename 已覆盖观察到的收尾消息形态,之后放宽匹配器不会破坏这道 seam。 +- **在 ui-primitives 里对传入的路径列表做解析**——把匹配策略放进通用渲染器,其他消费方会被动继承。解析器 seam 让策略留在持有者手里。 +- **经由 turn-tail chain 传递词表**——chain 是消息下方的渲染派发;提及要装饰的是消息内部的 markdown,只有抵达 MarkdownText 的数据才做得到。可选 service 就是那条数据通路,它的缺席即关闭态。 + +## 后果 + +提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts new file mode 100644 index 0000000000..badd9b845e --- /dev/null +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -0,0 +1,162 @@ +// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds +// a built write turn (zero model calls) whose closing message names the written +// file three ways: by unique basename (links), ambiguously (stays inert), and +// as a file the turn never touched (stays inert). Package tests cover the +// resolver in isolation; only the assembled application shows a real write's +// locations reaching the prose as an opener. The click itself is not driven +// here: it hands the path to the Host's opener, which would launch a real +// application on the machine running the suite (the produced-files restraint). +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SEED_ID = 'produced-file-mentions-web-e2e' +const DONE = 'FILE_MENTION_DONE' + +/** One-part text content for a built message. */ +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +/** The files the built turn writes; `notes.md` is named in prose but never written. */ +const WRITES = ['site/report.html', 'a/style.css', 'b/style.css'] + +/** Build a settled write turn whose closing prose mentions files in inline code. */ +function mentionFixture(): string { + const session = Session.create(SessionId('produced-file-mentions-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Produced file mentions', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + const calls = WRITES.map((path, index) => ({ + path, + callId: CallId(`file-mention-${String(index)}`), + args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), + })) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: 'write', + arguments: call.args, + })), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + const source = session.append('tool/call', { + turn: 1, + step: 1, + callId: call.callId, + name: 'write', + arguments: call.args, + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(`Created ${call.path}`), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, + step: 2, + message: createAssistantMessage({ + content: [{ + type: 'text', + text: [ + 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', + '', + DONE, + ].join('\n'), + }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: inline-code mentions of produced files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mentionFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Exactly one prose mention links: `report.html` resolves to the written + // path; the shared `style.css` basename and unwritten `notes.md` stay code. + const mentions = page.locator('[class*="markdown"] code button') + await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1) + expect(await mentions.first().innerText()).toBe('report.html') + expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html') + expect(await mentions.first().getAttribute('title')).toBe('site/report.html') + // The turn still ends with its produced-files row (all three writes). + expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1418cf2aa6..a147ddbcb8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", + "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index fdd1c41b80..2518ef28c8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: d708621dbeb99615b0864e6977c8d2f387ae5265 -README.zh.md: e2cf9c3b32c86c2da39f09e4804c2ac69a3569f7 +README.md: 6182fa1281eec50f8898634cb466291b6ef1f5ff +README.zh.md: 8b110ec7713dcda5972d0ea0d9a8042301906d99 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d708621dbe..6182fa1281 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e2cf9c3b32..8b110ec771 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 24325c714e..48a0558f34 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -313,6 +313,7 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bc1c6c7e32..8bb7f8aea4 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -15,7 +15,8 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -43,6 +44,8 @@ export interface AssistantMarkdownProps { onFork?: ((seq: number) => void) | undefined /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined + /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ + fileMentions?: ChatViewInjected['fileMentions'] | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,11 +89,25 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, + fileMentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the + // growing transcript: a settled turn's produced files are final, and a + // fresh identity per append would discard MarkdownText's cached parse for + // every settled closing message on every stream chunk. The window-prepend + // edge (a mid-turn window start later gaining earlier same-turn writes) + // leaves a mention unlinked until remount — never a wrong link. + const owner = turnTail?.owner + const mentions: MarkdownFileMentions | undefined = useMemo( + () => (owner === undefined ? undefined : fileMentions?.(owner)), + // Deliberately not `owner`: its identity changes per append while the + // seq-addressed vocabulary it yields does not. + [fileMentions, owner?.seq], + ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -107,7 +124,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ {blocks.map((block, i) => { switch (block.kind) { case 'text': return ( - + ) case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..c8ad485013 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,8 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + fileMentions, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -603,6 +604,7 @@ export function ChatView({ turnTail={actionSeqs.has(node.seq) ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} + fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 89f7986dba..7e811da7a1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -4,6 +4,7 @@ import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -158,6 +159,30 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` + * (optional-service convention): the chat view asks it for a closing message's + * inline-code vocabulary and threads the result into MarkdownText. Absent + * service — the providing plugin composed out of cordis.yml — turns the + * surface off; the prose renders inert code. + */ +export interface ChatFileMentions { + /** + * Mention vocabulary for the closing message the owner currency names. + * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @returns The resolver MarkdownText consumes, or undefined when the turn + * produced nothing worth linking. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module 'cordis' { + interface Context { + /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ + chatFileMentions: ChatFileMentions + } +} + /** * Owner currency of the chat view's turn-tail hole: the finalized snapshot * and the closing assistant's anchor. Registrants derive their own facts @@ -502,6 +527,13 @@ export interface ChatViewInjected { } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void + /** + * Prose file-mention vocabulary for one closing message, from the optional + * {@link ChatFileMentions} service (resolved lazily per call, so composing + * the provider in or out takes effect live). Undefined when the service is + * absent or the turn produced nothing worth linking. + */ + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 725868d57a..3ce8b64053 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -13,6 +13,7 @@ export type { export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { ConversationKey } from './locales.ts' export type { + ChatFileMentions, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..cb11a1a5fd 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -154,6 +154,8 @@ function makeHarness(init?: Partial) { inspectCall, chatScroll, forkAt, + // Absent-service default; mention tests override with a real resolver. + fileMentions: () => undefined, // Mirrors the real lookup chain (conversation namespace, then common). t: makeTranslate(zh, commonZh), } @@ -248,6 +250,45 @@ describe('chat-flow derivation', () => { expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) }) + it('threads the injected file-mention vocabulary into the closing prose only', () => { + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const h = makeHarness({ + nodes: [ + user(1, 'build it'), + assistant(2, 'writing `report.html` now', 1), + wrote(3, 'w', 'site/report.html'), + assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1), + ], + turnEnds: new Map([[1, 4]]), + }) + // Stub provider mirroring the real service: only produced files resolve. + h.props.fileMentions = owner => ({ + resolve: (value) => { + if (value !== 'report.html') return undefined + return { + open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + label: '打开 site/report.html', + title: 'site/report.html', + } + }, + }) + const view = render() + // Exactly one live mention: the closing message links, the mid-turn + // narration stays inert code, and the unknown file resolves to nothing. + const mentions = view.container.querySelectorAll('code button') + expect(mentions).toHaveLength(1) + const mention = view.getByRole('button', { name: '打开 site/report.html' }) + expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.click(mention) + // The vocabulary was built from the closing message's own owner currency. + expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') + }) + it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ee4c23c18c..3d25f13e1a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 -README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b +README.md: d6695f155907e7d92b35556588687b3f95e55b88 +README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b8b0ea2ef1..d6695f1559 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). + ## Model Experience None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. @@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests. ## Known Limitations and Deferred Work -- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. +- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a16535b8a8..be360a5a1f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,6 +8,8 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 + ## 模型体验 无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 @@ -18,4 +20,4 @@ ## 已知限制与暂缓事项 -- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 +- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。 diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index ab85869de2..0baff2872a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -6,18 +6,13 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' /** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ const SHOWN = 6 -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - /** Matched paths plus the opener and locale seats needed to present them. */ export type ProducedFilesProps = Pick & { matched: readonly string[] diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 6dc7bc4b84..816cdff520 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,16 +1,18 @@ /** * Deliverables plugin, browser half: registers the produced-files row into - * the chat view's turn-tail hole. All policy lives here — the derivation - * from the mutation tools' `locations`, the chip cap, and the copy — so - * composing this plugin out of cordis.yml removes the surface entirely; the - * owning view renders an empty hole at zero cost. + * the chat view's turn-tail chain, and provides the `chatFileMentions` + * service that links inline-code mentions of produced files in the closing + * prose. All policy lives here — the derivation from the mutation tools' + * `locations`, the mention matching, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes both surfaces entirely; + * the owning view renders an empty chain and inert prose at zero cost. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,4 +41,15 @@ export function apply(ctx: ClientContext): void { locale: NS, }, ProducedFiles), ) + // The prose side of the same vocabulary: the chat view reaches this face + // via ctx.get, so its absence — this plugin composed out — is the off state. + const t = ctx.locale.bind(NS) + const mentions: ChatFileMentions = { + forClosing(owner) { + const paths = producedForClosing(owner.nodes, owner.seq) + if (paths.length === 0) return undefined + return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + }, + } + ctx.provide('chatFileMentions', mentions) } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..4316ddf8e6 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths } + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * File-mention vocabulary over one turn's produced paths, for the closing + * message's prose: an inline-code token opens the file it names. A token + * resolves by exact path, or by being exactly the basename of exactly one + * produced path — a basename two paths share stays inert rather than + * guessing, so a mention link can never open the wrong file or 404. + * @param paths - The turn's produced paths (tool order, already deduped). + * @param openFile - The chat view's file opener. + * @param label - Localizes the accessible open-label for a resolved path. + * @returns The resolver MarkdownText consumes; the full path rides `title`, + * the same disambiguator the row's chips carry. + */ +export function producedFileMentions( + paths: readonly string[], + openFile: (path: string) => void, + label: (path: string) => string, +): MarkdownFileMentions { + return { + resolve(value) { + const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value) + if (path === undefined) return undefined + return { open: () => { openFile(path) }, label: label(path), title: path } + }, + } +} + +/** The single produced path whose basename is exactly `value`, else undefined. */ +function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined { + const matches = paths.filter(path => basename(path) === value) + return matches.length === 1 ? matches[0] : undefined +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..65af40d642 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -13,9 +13,10 @@ import type { AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -141,6 +142,33 @@ describe('ProducedFiles row', () => { }) }) +describe('producedFileMentions resolver', () => { + const label = (path: string) => `打开 ${path}` + + it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => { + const opened: string[] = [] + const resolver = producedFileMentions( + ['out/index.html', 'a/style.css', 'b/style.css'], + (path) => { opened.push(path) }, + label, + ) + // Unique basename resolves to its full path; the full path rides title. + const byBasename = resolver.resolve('index.html') + expect(byBasename?.label).toBe('打开 out/index.html') + expect(byBasename?.title).toBe('out/index.html') + byBasename?.open() + expect(opened).toEqual(['out/index.html']) + // An exact path resolves even when its basename is ambiguous. + const exact = resolver.resolve('a/style.css') + expect(exact?.title).toBe('a/style.css') + // A basename two paths share stays unresolved rather than guessing, + // and so does a token naming nothing the turn wrote. + expect(resolver.resolve('style.css')).toBeUndefined() + expect(resolver.resolve('notes.md')).toBeUndefined() + expect(basename('a\\b\\c.txt')).toBe('c.txt') + }) +}) + describe('package shells', () => { it('the node half mounts inert and the invariant companion registers ownership', async () => { // The node half is deliberately inert; mounting it must simply not throw. @@ -172,7 +200,24 @@ describe('plugin registration', () => { await fiber.await() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + // The prose face is live while the plugin is: a produced turn yields a + // resolver whose matches open through the owner-supplied opener. + const opened: string[] = [] + const owner = { + nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], + seq: 3, + openFile: (path: string) => { opened.push(path) }, + } + const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') + const mentions = service?.forClosing(owner) + mentions?.resolve('report.html')?.open() + expect(opened).toEqual(['site/report.html']) + // A turn that produced nothing yields no vocabulary at all. + expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. + expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 47cebf796f..f7598d7a90 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4 -README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687 +README.md: a5f35c9a41008921c6a1e3a4de3fd3f38af926c0 +README.zh.md: 54c70ed29e73e196a9be60ddb794c12187dbe0c5 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7571cb4842..a5f35c9a41 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index fa0c3f2402..54c70ed29e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index fe0235c496..30a1dae402 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -40,7 +40,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7e31857053..19375be18e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -241,3 +241,23 @@ background: var(--dsw-alias-bg-base); object-fit: contain; } + +/* Inline file mention: a real file named in prose is the same affordance as a + tool row's path link, so it reads the same — underlined at rest. */ +.fileMention { + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; + cursor: pointer; +} + +.fileMention:hover { + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index bb4b62514c..4dff78d784 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,20 +19,25 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ -function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] { +function renderSettled( + text: string, + codeLabels: MarkdownCodeLabels | undefined, + fileMentions: MarkdownFileMentions | undefined, +): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, codeLabels, + fileMentions, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -96,6 +101,7 @@ class StreamingRenderer { const frozenContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -113,6 +119,7 @@ class StreamingRenderer { const tailContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -137,28 +144,33 @@ class StreamingRenderer { * the finalize swap) and parses incrementally across chunks; `codeLabels` * forwards localized copy-button labels to fence CodeBlocks — pass a * reference-stable object (memoized per locale revision), because a new - * identity discards the streaming render cache mid-message. + * identity discards the streaming render cache mid-message. `fileMentions` + * links inline-code tokens its resolver recognizes as real files; this is + * the single streaming gate — it applies to settled renders only, because a + * streaming message's vocabulary is not final and frozen cached elements + * must not bake in handlers that could go stale. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { text: string streaming?: boolean codeLabels?: MarkdownCodeLabels | undefined + fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) const streamLabelsRef = useRef(codeLabels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels) + return renderSettled(text, codeLabels, fileMentions) } if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { streamRef.current = new StreamingRenderer(codeLabels) streamLabelsRef.current = codeLabels } return streamRef.current.render(text) - }, [text, streaming, codeLabels]) + }, [text, streaming, codeLabels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 4ac8b4dbc1..452e0fc475 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -99,6 +99,21 @@ export function collectReferenceTargets( } } +/** + * File-mention affordance for inline code: the owner resolves an authored + * token to the file it names, using its own vocabulary of real files — the + * renderer never guesses at what looks like a path. + */ +export interface MarkdownFileMentions { + /** + * Resolve one inline-code token. + * @param value - The authored token, exactly as written. + * @returns The opener with its accessible label and full-path title, or + * undefined when the token names no known file — it then stays inert code. + */ + resolve(value: string): { open: () => void; label: string; title: string } | undefined +} + /** * One render pass's state: immutable options and targets plus the footnote * numbering accumulated in document order while references render. @@ -108,6 +123,10 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ + readonly fileMentions: MarkdownFileMentions | undefined + /** Inside an anchor's children: interactive mentions must not nest there. */ + readonly inLink?: boolean /** Reference targets visible to this pass. */ readonly targets: ReferenceTargets /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */ @@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte // authored text, not a parsed destination, so no normalizeUri: port, // path, and query render unchanged. const href = inlineCodeHttpUrl(value) - return {href === undefined ? value : renderSafeLink(href, [value], 'link')} + if (href !== undefined) return {renderSafeLink(href, [value], 'link')} + // A token the owner's file-mention vocabulary recognizes opens that + // file; the resolver, not this renderer, decides what names a file. + // Inside an anchor the token stays inert — a button cannot nest there. + const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value) + if (mention !== undefined) { + return ( + + + + ) + } + return {value} } case 'html': // No HTML parser enters the pipeline: raw HTML stays literal text. @@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, context), key) + return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -460,14 +499,14 @@ function renderLinkReference( context: MarkdownRenderContext, ): ReactNode { const definition = context.targets.definitions.get(node.identifier.toUpperCase()) - const children = renderChildren(node.children, context) if (definition === undefined) { // The grammar only emits references whose definitions exist somewhere in // the same parse, but incremental segments and hand-built trees may still - // present unresolved ones: revert to the bracketed source text. - return {'['}{children}{referenceSuffix(node)} + // present unresolved ones: revert to the bracketed source text — which is + // not an anchor, so mentions inside it stay live. + return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } - return renderAnchor(definition.url, children, key) + return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx index 48dd03f5c5..48f59c7479 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext { return { streaming: false, codeLabels: undefined, + fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 5066e32cd2..89ffd32346 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -148,6 +148,49 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code a')).toBeNull() }) + it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => { + const opened: string[] = [] + const fileMentions = { + resolve: (value: string) => value === 'index.html' || value === 'out/index.html' + ? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' } + : undefined, + } + const source = [ + '`index.html`', + '`other.css`', + '`https://example.com/`', + // Inside an anchor the mention stays inert code: a button cannot nest there. + '[see `out/index.html`](https://example.com/doc)', + '[ref `out/index.html`][target]', + '[target]: https://example.com/ref', + '```', + 'index.html', + '```', + ].join('\n\n') + const { container } = render() + + const mention = screen.getByRole('button', { name: 'Open out/index.html' }) + expect(mention.closest('code')).not.toBeNull() + // The full path rides title, the same disambiguator the row's chips carry. + expect(mention.getAttribute('title')).toBe('out/index.html') + fireEvent.click(mention) + expect(opened).toEqual(['index.html']) + // Exactly one live mention: the two inside anchors declined, and an + // unresolved token plus fenced code stay inert. + expect(container.querySelectorAll('code button')).toHaveLength(1) + expect(container.querySelectorAll('a code button, a button')).toHaveLength(0) + expect(screen.getByText('other.css').closest('button')).toBeNull() + // URL promotion wins before the resolver sees a token. + expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull() + + // Streaming renders keep mentions off — the one gate lives here: cached + // frozen elements must not bake in handlers that could go stale. + const streamed = render( + , + ) + expect(streamed.container.querySelector('button')).toBeNull() + }) + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { const extension = cjkFriendlyStrong() expect(cjkFriendlyStrong()).toBe(extension) diff --git a/tsconfig.host.json b/tsconfig.host.json index ad2b5e32f1..d4f7eb8650 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", + "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 7ef39dc4b02795260c483c449b632303f95f6634 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:00:47 -0700 Subject: [PATCH 02/12] refactor(deliverables): reuse the chain claim test for the mention vocabulary --- packages/client/ui-deliverables/src/client/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 816cdff520..81b2f61b79 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -46,8 +46,10 @@ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { forClosing(owner) { - const paths = producedForClosing(owner.nodes, owner.seq) - if (paths.length === 0) return undefined + // Same claim test the turn-tail chain entry runs: no produced files, + // no vocabulary — the two surfaces agree by construction. + const paths = selectProducedFiles(owner) + if (paths === null) return undefined return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) }, } From 4c45355012bdc0d86d0fb8abe7e37c56b1e7c351 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:06:21 -0700 Subject: [PATCH 03/12] style(web): mention chips take the markdown anchor language Link-blue at rest with hover underline, matching URL-promoted inline code; an at-rest underline collides with monospace descenders inside the code chip. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 4 ++-- .../2026-08-07-web-inline-file-mentions.md | 2 +- .../2026-08-07-web-inline-file-mentions.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 20 ++++++++++--------- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml index e8fe387234..8153420c3d 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.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 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md -2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 -2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa +2026-08-07-web-inline-file-mentions.md: 581efb5a9eb497e030d19118d52d003b37118108 +2026-08-07-web-inline-file-mentions.zh.md: 25c8ca6e106cd877a4d46a7b1338728e96771fc1 diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md index 79c6bddd50..581efb5a9e 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -25,4 +25,4 @@ The produced-files row lists a turn's output, but the closing message usually al ## Consequences -The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. +The mention and the row are two affordances for one fact (full path as `title` on both); the mention itself wears the markdown sheet's anchor language — link-blue at rest, hover underline — because an at-rest underline collides with monospace descenders inside the code chip. `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md index ddc54d3b43..25c8ca6e10 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -25,4 +25,4 @@ Status: implemented ## 后果 -提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 +提及与产物行是同一事实的两个交互面(两者都以完整路径作 `title`);提及本身采用 markdown 样式表的锚点语言——静止为链接蓝、悬停出下划线——因为静止下划线在 code 胶囊里会压住等宽字的下伸部。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 3d25f13e1a..7c2cb356ec 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: d6695f155907e7d92b35556588687b3f95e55b88 -README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b +README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 +README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index d6695f1559..189dedd88f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,7 +8,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). -The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). ## Model Experience diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index be360a5a1f..bfcec3c546 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,7 +8,7 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 -收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 19375be18e..b62e66e86e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -242,22 +242,24 @@ object-fit: contain; } -/* Inline file mention: a real file named in prose is the same affordance as a - tool row's path link, so it reads the same — underlined at rest. */ +/* Inline file mention: the same link language this sheet gives anchors (and + thereby URL-promoted inline code) — link-blue at rest, underline only on + hover/focus. An underline at rest reads badly inside the code chip, where + it collides with monospace descenders and the pill background. */ .fileMention { margin: 0; padding: 0; border: none; background: none; font: inherit; - color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-decoration-color: var(--dsw-alias-label-quaternary); - text-underline-offset: 3px; + color: var(--dsw-alias-state-business-primary); + text-decoration: none; cursor: pointer; } -.fileMention:hover { - color: var(--dsw-alias-label-primary); - text-decoration-color: currentColor; +.fileMention:hover, +.fileMention:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); + text-underline-offset: 3px; } From 5934bbc32e34d733a81a57a9daf0e9a9a31caa9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:17:14 +0800 Subject: [PATCH 04/12] docs(skills): preserve browser GIF evidence chains --- ...08-08-browser-gif-evidence-chain.i18n.yaml | 6 +++ .../2026-08-08-browser-gif-evidence-chain.md | 37 +++++++++++++++++++ ...026-08-08-browser-gif-evidence-chain.zh.md | 37 +++++++++++++++++++ .agents/skills/record-browser-gif/SKILL.md | 32 ++++++++++------ 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml new file mode 100644 index 0000000000..ea763ef0e8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.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/process/2026-08-08-browser-gif-evidence-chain.md +2026-08-08-browser-gif-evidence-chain.md: 9fe24b211e7094d4769af50c8b0ceae5c43fb4be +2026-08-08-browser-gif-evidence-chain.zh.md: a84ea46373d8684389ce0c8c61887906e1fdc025 diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md new file mode 100644 index 0000000000..9fe24b211e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md @@ -0,0 +1,37 @@ +# Agent Note: Browser GIFs preserve one evidence chain + +Status: implemented + +English | [中文](2026-08-08-browser-gif-evidence-chain.zh.md) + +## Problem + +A browser-demo storyboard can contain individually truthful screenshots without proving one truthful execution. Reusing global application state can admit old settings or sessions, capture automation can accidentally combine frames from separate model runs, and a chat transcript can show a successful fallback without exposing the tool rejection that caused it. Fuzzy accessible-name matching can also accept prompt echoes or descendant text instead of the intended result. + +Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would change the provenance. After publication, a successful git push does not prove that a private-repository GIF is fetchable or that GitHub recognizes the pull-request Markdown as an image. + +## Decision + +The [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) workflow treats one storyboard as one evidence chain pinned to an exact pull-request head. Each run uses fresh `DSH_HOME`, `DSH_AGENTS_HOME`, workspace, and session state, and every published frame comes from the same server and model-backed scenario run. A failed capture run is discarded and repeated from fresh roots rather than combined with another run. + +Browser automation waits for unique, exact semantic states. When the claim concerns a tool call, rejection, or recovery, the storyboard includes a detail or trajectory frame that identifies the tool, shows its status or stable error code, and shows the downstream result. The final encoded GIF remains the verification subject; when a viewer cannot animate it, representative frames are decoded from that GIF instead of treating source screenshots as equivalent evidence. + +The available browser-control workflow remains preferred. When it is unavailable, the recorder uses the repository-declared Playwright dependency in an isolated headless browser rather than installing another driver or opening the user's browser. A native production surface may be replaced only through normal application configuration with an official browser-operable production backend, and that override is stated in the provenance. Fixtures, mock transports, synthetic events, and test-only hooks do not substantiate a real-production claim. + +Publication verifies the boundary again. The assets branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type. This proves the repository-member review path only; the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL. Immediately before the pull-request body changes, the live head must still equal the recorded head; GitHub's Markdown renderer must then produce the expected image without changing that code head. + +## Alternatives considered + +**Allow frames from separate runs when their visible states look equivalent.** Visual similarity does not establish shared state, causal order, or one scenario execution. Re-recording costs another real round but preserves the claim the storyboard makes. + +**Use the chat transcript as sufficient proof of tool recovery.** A final answer proves that the task completed, but it can hide which tool ran, whether the failure was structured, and whether the model recovered from that failure. A trajectory or detail frame carries those facts directly. + +**Replace inaccessible native UI with a fixture or test hook.** That makes automation easier by changing the product path under observation. Selecting an official production backend through normal configuration keeps the exercised implementation real and makes the narrower mode explicit. + +**Trust a successful assets-branch push or an anonymous fetch.** A push proves only that git accepted bytes, while private repositories intentionally reject unauthenticated raw requests. Authenticated byte verification plus GitHub Markdown rendering tests the two publication boundaries that reviewers use. + +## Consequences + +GUI evidence now establishes one causal execution rather than a collage of plausible states, and reviewers can inspect both a structured tool failure and the completed result. Publication detects stale pull-request heads, corrupted or misplaced media, and invalid image Markdown before the body is treated as finished. + +The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus authenticated publication checks. Headless recordings can use fewer production backends than an interactive desktop, and every such selection remains part of the stated provenance. diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md new file mode 100644 index 0000000000..a84ea46373 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 浏览器 GIF 保留单一证据链 + +Status: implemented + +[English](2026-08-08-browser-gif-evidence-chain.md) | 中文 + +## 问题 + +浏览器演示的分镜可以由每张都真实的截图组成,却无法证明这些截图来自同一次真实执行。复用应用全局状态可能引入旧设置或旧会话;录制自动化可能误将不同模型运行的画面合并;聊天 transcript(文本记录)可能显示降级处理成功,却没有揭示触发降级的工具拒绝。按无障碍名称进行模糊匹配,还可能误把提示词回显或后代文本当成预期结果。 + +无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面会改变证据来源。发布之后,git 推送成功也不能证明私有仓库中的 GIF 可以获取,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 + +## 决策 + +[`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) 工作流将一套分镜视为一条证据链,并将其固定到精确的 PR head。每次运行都使用全新的 `DSH_HOME`、`DSH_AGENTS_HOME`、工作区和会话状态,所有发布帧均来自同一个服务器及同一次由模型驱动的场景执行。录制失败时,丢弃该次运行并从全新的状态根目录重新执行,不与另一次运行合并。 + +浏览器自动化会等待唯一且精确的语义状态。如果需要证明工具调用、拒绝或恢复,分镜就必须包含详情帧或轨迹帧:标明工具、显示其状态或稳定错误码,并展示后续结果。最终编码出的 GIF 始终是验证对象;如果查看器无法播放动画,应从该 GIF 中解码出代表性帧,而不能将源截图视为等效证据。 + +仍应优先使用已有的浏览器控制工作流。如果该工作流不可用,录制程序应在隔离的无头浏览器中使用仓库已声明的 Playwright 依赖,而不是安装其他驱动或打开用户的浏览器。只有通过正常应用配置选用官方且可由浏览器操作的生产后端,才能替换原生生产界面,并且必须在证据来源说明中注明这一覆盖。fixture(测试前置数据)、mock 传输层、合成事件和测试专用钩子均不能支撑真实生产实现的主张。 + +发布环节会再次验证边界。资产分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致;对于私有仓库中的资产,应通过经身份验证的 API 或原始内容请求,检查其路径、字节大小、校验和、响应状态和媒体类型。这只能证明仓库成员的评审访问路径;[文档站点图片决策](2026-08-06-doc-site-carries-its-images.md)解释了公共站点为何不能依赖私有的原始内容 URL。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同;随后还必须确认 GitHub 的 Markdown 渲染器生成了预期图片,且代码 head 没有改变。 + +## 曾考虑的替代方案 + +**只要可见状态看起来等价,就允许使用不同运行的画面。**视觉相似不能证明各画面共享同一状态、具有因果顺序或来自同一次场景执行。重新录制需要再执行一次真实模型场景,但能维持整套分镜所表达的主张。 + +**将聊天 transcript 视为工具恢复的充分证据。**最终答案能证明任务已经完成,却可能隐藏调用了哪个工具、失败是否为结构化失败,以及模型是否从该失败中恢复。轨迹帧或详情帧可以直接承载这些事实。 + +**使用 fixture 或测试钩子替换无法访问的原生 UI。**这种做法通过改变被观察的产品路径来简化自动化。通过正常配置选用官方生产后端,既能保持受测实现真实,也能明确表述所采用的较窄运行模式。 + +**相信资产分支推送成功或匿名请求成功。**推送只能证明 git 接受了相应字节,而私有仓库会有意拒绝未经身份验证的原始内容请求。经身份验证的字节校验与 GitHub Markdown 渲染验证,覆盖了评审者实际使用的两道发布边界。 + +## 后果 + +GUI 证据现在能证明一次具有因果关系的执行,而不是将若干可信状态拼成集合;评审者既可以检查结构化的工具失败,也可以检查最终完成的结果。在 PR 正文被视为完成之前,发布验证可以发现陈旧的 PR head、损坏或位置错误的媒体文件,以及无效的图片 Markdown。 + +该工作流会占用额外的临时状态;录制失败后,可能需要重新执行一次由真实模型驱动的场景;通常还会增加一张详情帧和经身份验证的发布检查。相比交互式桌面,无头录制可使用的生产后端更少;每次选择这类后端时,都必须将其写入证据来源说明。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index 074b8b176e..c34c47d9d7 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -7,6 +7,8 @@ description: Record browser or Web UI interaction demos as optimized GIFs using Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the assets-branch workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. +The [evidence-chain decision](../../notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md) owns why one storyboard comes from one isolated run and why publication revalidates both the artifact and the demonstrated pull-request head. + ## Every GUI pull request includes a GIF A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the assets-branch workflow](#publish-to-an-assets-branch). @@ -25,22 +27,24 @@ The GIF's provenance is part of the evidence and must be real: a real server boo A GIF for a specific pull request demonstrates that pull request's tree, so stage per pull request: 1. Build the branch tree being demonstrated — here, `pnpm run build && pnpm run build:web` — from the worktree that holds that branch. A GIF recorded against another branch's build misattributes the evidence. -2. Boot one server per port from that tree, giving each recording a fresh scratch workspace directory so leftover sessions cannot appear in frames. Source the root `.env` for the API key through the application's normal path; never echo the key. -3. Start a new session for each recorded scenario so earlier turns do not pollute the frames. +2. Boot one server per port from that tree with fresh scratch `DSH_HOME`, `DSH_AGENTS_HOME`, workspace, and session state so settings or sessions from another run cannot affect the evidence. Source the root `.env` for the API key through the application's normal path; never echo the key. +3. Treat one storyboard as one evidence run: every published frame comes from that server and those state roots, workspace, session, and model-backed scenario run. If capture automation fails, discard its frames and rerun from fresh roots; never splice frames from separate runs. 4. When switching between pull requests, stop the old server by PID or an exact match on its command line. A broad `pkill -f` pattern can match and kill the shell that launched it — including your own. ## Record the flow -1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required. +1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. If it is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback in the provenance. 2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. -3. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. -4. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on. -5. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-