From ae22f461f35fce66ec4210bccd37f1c165a2e0c6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:13 +0800 Subject: [PATCH 01/43] feat(session-log-export): add Session Markdown copy Add Copy as Markdown to the Session Header more-actions menu. A new authenticated /api/session.markdown route reads complete live-preferred history through sessionQuery.readSession and renders append-only user, assistant, and tool events as Markdown. Compaction surface replacements, reasoning, injected context, and request metadata stay out of the transcript. One plugin-wide copy operation owns the browser clipboard, with an overlay toast for success and failure. --- .../copy-markdown.expected.md | 75 +++ apps/web/tests/navigation-panes.e2e.ts | 38 ++ docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../src/client/slot-catalog.ts | 1 + .../references/packages.md | 2 +- .../session-log-export/README.i18n.yaml | 58 +- .../session-log-export/README.md | 35 +- .../session-log-export/README.zh.md | 35 +- .../session-log-export/package.json | 4 +- .../src/client/CopyToast.tsx | 39 ++ .../src/client/HeaderAction.tsx | 38 +- .../src/client/copy-controller.ts | 121 +++++ .../session-log-export/src/client/index.ts | 23 +- .../session-log-export/src/client/locales.ts | 6 + .../session-log-export/src/index.ts | 67 ++- .../session-log-export/src/markdown.ts | 302 +++++++++++ .../session-log-export/src/routes.ts | 10 +- .../tests/client-apply.client.spec.tsx | 24 +- .../tests/copy-controller.client.spec.ts | 175 ++++++ .../tests/copy-toast.client.spec.tsx | 56 ++ .../tests/header-action.client.spec.tsx | 52 +- .../tests/markdown-route.host.spec.ts | 176 ++++++ .../tests/markdown.host.spec.ts | 508 ++++++++++++++++++ .../session-log-export/tsconfig.client.json | 3 + .../session-log-export/tsconfig.host.json | 1 + pnpm-lock.yaml | 3 + 27 files changed, 1788 insertions(+), 68 deletions(-) create mode 100644 apps/web/tests/expected/navigation-panes/copy-markdown.expected.md create mode 100644 packages/session-query/session-log-export/src/client/CopyToast.tsx create mode 100644 packages/session-query/session-log-export/src/client/copy-controller.ts create mode 100644 packages/session-query/session-log-export/src/markdown.ts create mode 100644 packages/session-query/session-log-export/tests/copy-controller.client.spec.ts create mode 100644 packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx create mode 100644 packages/session-query/session-log-export/tests/markdown-route.host.spec.ts create mode 100644 packages/session-query/session-log-export/tests/markdown.host.spec.ts diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md new file mode 100644 index 0000000000..1be7dec876 --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -0,0 +1,75 @@ +# NavScenario: first run bash to + +> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. + +
3 previous messages + +**Tool: `bash`** + +```json +{ + "command": "echo NAVIGATION_OK", + "description": "Print NAVIGATION_OK" +} +``` + +**Result** + +``` +NAVIGATION_OK +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-a.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-a.md +file + +1: # alpha nav + +(End of file - total 1 lines) + +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-b.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-b.md +file + +1: # beta nav + +(End of file - total 1 lines) + +``` + +
+ +FIRST_DONE + +> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. + +## Navigation Summary + +- alpha nav +- beta nav + +``` +echo WATERFALL +``` diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 75202aea47..92999d864f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -31,6 +31,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') +const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -412,6 +413,43 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }, 120_000) + it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) + await ensureSeedOpen(page) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + const markdownResponse = page.waitForResponse(response => + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) + const copyButton = page.getByRole('button', { name: 'More actions' }) + await copyButton.click() + await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click() + const response = await markdownResponse + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') + await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + + const markdown = await page.evaluate(() => navigator.clipboard.readText()) + const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') + // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. + await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('# NavScenario: first run bash to') + expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') + expect(normalized).toContain('
3 previous messages') + expect(normalized).toContain('**Tool: `bash`**') + expect(normalized).toContain('NAVIGATION_OK') + expect(normalized).toContain('FIRST_DONE') + expect(normalized).toContain('Navigation Summary') + expect(normalized).not.toContain('The user wants me to follow') + expect(normalized).not.toContain('{{system}}') + expect(normalized).not.toContain('request/header') + expect(normalized.endsWith('\n')).toBe(true) + expect(normalized.endsWith('\n\n')).toBe(false) + + await copyButton.click() + expect(await page.getByRole('menuitem', { name: 'Copy as Markdown' }).isDisabled()).toBe(false) + await page.keyboard.press('Escape') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8031ee9b3b..2ee5fbad73 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2859,7 +2859,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 393f5827de..eb55be3fc1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2861,7 +2861,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 50bcbe529a..7c4cd14488 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3399,6 +3399,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', 'client-ui-workspace RowActionToast id \'workspace.row-toast\'', + 'session-log-export SessionMarkdownCopyToast id \'session-log-download.copy-toast\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index cd38b17726..fcf4213fd2 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -381,7 +381,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | Package | Config | Description | |---|---|---| -| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log export command and shared download dialog | +| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log ZIP export and Session Markdown copy | | `@deepseek-ai/dsh-session-query-sqlite` | yes | Concrete ctx.sessionQuery backend with SQLite FTS5 search | ## settings diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 5bcd32d6c0..94e9c5ec76 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -3,20 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md /: - en: 3b67077a6dfacd19 - zh: e5b78d039d310dc2 + en: 2ed4e25828621cc5 + zh: e9aa38b58d716093 /deepseek-ai-dsh-session-log-export: en: eea87f5da57ff494 zh: db388203522b840f /deepseek-ai-dsh-session-log-export/summary: - en: 26be400b3e9f7bb7 - zh: b6e78f1f417a0e4e + en: 9268b3aaeaf82673 + zh: 99cc5b0de0b5a8bd /deepseek-ai-dsh-session-log-export/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-session-log-export/use-this-package: - en: 1135b03eb3423c74 - zh: 2fd2a18d503df70c + en: f37f81af3601784d + zh: 89b47be0660c8b8b +/deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: + en: 36a79c163dd92469 + zh: b31cb116dc950941 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 2b9dbec4877d332e zh: dcf6638938eedb01 @@ -33,38 +36,41 @@ en: 44be1f1d1903d12a zh: c8f1eb5b92b71a30 /deepseek-ai-dsh-session-log-export/use-this-package/failures: - en: 3fc8985946d67024 - zh: 5bb40a4b531d68f4 + en: 3c274dea47f7f2c6 + zh: e83b2d6ca29f22ec /deepseek-ai-dsh-session-log-export/understand-the-implementation: en: 7cb82a6851405f95 zh: b8c525170d19dc6c /deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split: - en: d135d90a02c6d829 - zh: 587d7ff2d17b056b + en: 3bb8e18b107c353f + zh: 3cc45ce2b5699066 /deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow: - en: 028ea3bff05250ba - zh: 63250c4d3369ebbd + en: bd46d2fa1273a6a4 + zh: 4d3eb2cf0a9bf12b +/deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: + en: fc740b3b80678249 + zh: 106f93ce651127d4 /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 /deepseek-ai-dsh-session-log-export/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control: - en: 55c1a61a041a2aeb - zh: 48e05043cc42304f -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/what-the-model-sees: - en: 6b3c3cfc5d68d5f9 - zh: b1689c57ee3b4ccb -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/token-effect: - en: 380535e564a4ea2c - zh: 2672eac76afd90f5 -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/kv-cache-effect: - en: 78163c30fdf0b43d - zh: 165043e307b38cb3 +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls: + en: fc738fb50c699115 + zh: d9184c11b904d36c +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/what-the-model-sees: + en: e7784b93213942e5 + zh: 598c2ce6af076d2f +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/token-effect: + en: 312b2def8b2cd7d4 + zh: 9af5e3a7cd12ce65 +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/kv-cache-effect: + en: 61e7dbf79010d071 + zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 9978d234cbfe7e73 - zh: 9ba0b12300ff4d38 + en: 0c0c46a6ec873533 + zh: 7c4183401c37d356 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 79eefc0a3b..01155f799b 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -1,5 +1,5 @@ --- -description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command." +description: "Web Session-log ZIP export and Markdown copy: Host streaming, authenticated routes, Session Header actions, and the /export command." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` lets the Web interface download a session's full history: a `Download session log` menu item under the Session Header's more-actions button and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. +`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,9 +25,17 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Download session log` from the Session Header's more-actions menu or type `/export`; the browser downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. -When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; export remains available independently. +### Copy as Markdown + +`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. + +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. The text ends with exactly one newline. + +The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header disables its copy row until the operation settles, then restores it. A success or failure toast is shown through the root overlay, so it survives the menu closing or the Session changing. + +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. ### When to choose it @@ -63,7 +71,7 @@ Attachment collection reads declared content fields of built-in Session events a ### Failures -The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. +The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown copy reports a failure toast when the Host route is unavailable or the browser rejects clipboard access; it never reports success for a partial or missing transcript. ----- @@ -77,7 +85,7 @@ This section explains how the package wires the export control and points at the ### Design split -The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download. +The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download. The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. @@ -87,6 +95,12 @@ Both entry paths issue a `HEAD` preflight to the document-relative `api/session. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. +### Markdown copy flow + +The Header asks the Markdown copy controller for the clicked Session. The controller fetches the document-relative `api/session.markdown?sessionId=` route and writes the response text with the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title through the same query service, and passes the raw events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. + +The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its success notice always describe the first click's Session. Disposal aborts the fetch and suppresses a late clipboard write or notice. +
----- @@ -106,19 +120,19 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -### Human `/export` control +### Human export and copy controls #### What the model sees -Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history. +Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history. The Markdown copy action is also human-only: it reads the committed log, writes the browser clipboard, and appends no Session event. #### Token effect -Zero. The command creates no model turn. +Zero. The command creates no model turn, and neither export control changes a request. #### KV Cache effect -None. The log-only command lifecycle and browser download do not change the derived request prefix. +None. The log-only command lifecycle, browser download, and clipboard write do not change the derived request prefix. ## Known Limitations and Deferred Work @@ -129,6 +143,7 @@ These limits define when this package is a poor fit or needs special operational - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. +- **Markdown contains committed history only** — an unsettled live token chunk has no committed text, so it is absent from the copied transcript. Compaction and fork inheritance follow the raw log rather than the current model context. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 148102ec04..e067ac8e43 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。" +description: "Web 会话日志 ZIP 导出与 Markdown 复制:Host 流式传输、认证路由、Session Header 操作与 /export 命令。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 更多操作按钮下的 `下载 Session 日志` 菜单项与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控件和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 +`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,9 +25,17 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 -挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;导出功能保持独立可用。 +### 复制为 Markdown + +`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。 + +格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。文本以恰好一个换行结尾。 + +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 在操作结束前禁用各自的复制行,结束后恢复。成功或失败浮层通过根 overlay 显示,因此菜单关闭或 Session 切换后仍然存在。 + +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 ### 何时选择 @@ -63,7 +71,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 失败 -当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 +当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Host 路由不可用或浏览器拒绝剪贴板访问时,Markdown 复制显示失败浮层;它不会为不完整或缺失的记录报告成功。 ----- @@ -77,7 +85,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 设计拆分 -本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 +本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。 @@ -87,6 +95,12 @@ Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 +### Markdown 复制流程 + +Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取文档相对的 `api/session.markdown?sessionId=` 路由,并用共享剪贴板助手写入响应文本。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,通过同一查询服务折叠最新标题,再把原始事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 + +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其成功提示始终描述第一次点击的 Session。释放时中止请求,并抑制迟到的剪贴板写入或提示。 +
----- @@ -106,19 +120,19 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ## 模型体验 -### 用户 `/export` 控制 +### 用户导出与复制控制 #### 模型看到什么 -无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。 +无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。Markdown 复制操作同样只属于人类:它读取已提交日志、写入浏览器剪贴板,且不追加 Session 事件。 #### Token 影响 -为零。该命令不创建模型轮次。 +为零。该命令不创建模型轮次,两个导出控件也不会改变请求。 #### KV Cache 影响 -无。仅日志命令生命周期与浏览器下载不会改变派生请求前缀。 +无。仅日志命令生命周期、浏览器下载与剪贴板写入不会改变派生请求前缀。 ## 已知限制与延期工作 @@ -129,6 +143,7 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 +- **Markdown 只包含已提交历史**——未稳定的实时 token 分块没有已提交文本,因此不会出现在复制的记录中。压缩与 fork 继承遵循原始日志,而非当前模型上下文。 ### 开发备注 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index ec82c4759c..d5c457baa6 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-log-export", - "description": "Web Session-log export command and shared download dialog", + "description": "Web Session-log ZIP export and Session Markdown copy", "version": "0.2.1-rc.2", "publishConfig": { "access": "public" @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", @@ -75,6 +76,7 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session" ], diff --git a/packages/session-query/session-log-export/src/client/CopyToast.tsx b/packages/session-query/session-log-export/src/client/CopyToast.tsx new file mode 100644 index 0000000000..3d49e52e04 --- /dev/null +++ b/packages/session-query/session-log-export/src/client/CopyToast.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { SessionMarkdownCopyState } from './copy-controller.ts' +import { NS } from './locales.ts' + +/** Copy state and dismissal bound into the root overlay entry. */ +export interface SessionMarkdownCopyToastInjected { + hooks: { sessionMarkdownCopy: ObservableSnapshot } + dismiss: () => void +} + +/** Props of the root overlay copy notice. */ +export type SessionMarkdownCopyToastProps = PropsLocale + & InjectFace + +/** + * Show the settled copy outcome outside the Session Header and menu lifetime. + * @param props - copy notice hook, dismissal action, and localized copy. + * @returns the success or failure toast, or null while no notice shows. + */ +export function SessionMarkdownCopyToast({ + useSessionMarkdownCopy, dismiss, t, +}: SessionMarkdownCopyToastProps): ReactNode { + const notice = useSessionMarkdownCopy(state => state.notice) + if (notice === null) return null + return notice.kind === 'success' + ? + : ( + } + onDone={dismiss} + /> + ) +} diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index 5104dee910..55af4ab1e0 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -3,14 +3,31 @@ import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { Button, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, IconPaperPlaneOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, + IconPaperPlaneOutlineRegular, Menu, +} from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' +import type { SessionMarkdownCopyState } from './copy-controller.ts' import css from './HeaderAction.module.css' -/** Session download controls with observable feedback availability and a Session feedback action. */ -export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected { - hooks: SessionLogDownloadDialogInjected['hooks'] & { feedbackAvailable: ObservableSnapshot } +/** Markdown copy state and action bound into the Session Header contribution. */ +export interface SessionMarkdownCopyHeaderInjected { + hooks: { sessionMarkdownCopy: ObservableSnapshot } + /** + * Copy one complete Session transcript to the clipboard. + * @param sessionId - Session captured by the menu click. + */ + copyMarkdown: (sessionId: SessionId) => Promise +} + +/** Session download controls plus Markdown copy and observable feedback availability. */ +export interface SessionLogDownloadHeaderInjected + extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected { + hooks: SessionLogDownloadDialogInjected['hooks'] + & SessionMarkdownCopyHeaderInjected['hooks'] + & { feedbackAvailable: ObservableSnapshot } /** * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads. * @param sessionId - Session whose feedback form to open. @@ -22,14 +39,19 @@ export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDial export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace /** - * Render the Session Header menu with download and optional feedback actions. - * @param props - Session runtime, download controller, and localized copy. + * Render the Session Header menu with Markdown copy, download, and optional + * feedback actions. + * @param props - Session runtime, download and copy state, and localized copy. * @returns the persistent Header action and Session-scoped dialog. */ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { - const { sessionId, useSessionLogDownload, useFeedbackAvailable, request, openFeedback, t } = props + const { + sessionId, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable, + request, copyMarkdown, openFeedback, t, + } = props const feedbackAvailable = useFeedbackAvailable(value => value) const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) + const copying = useSessionMarkdownCopy(state => state.copying) const busy = entry?.status === 'downloading' const [open, setOpen] = useState(false) @@ -41,12 +63,14 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderPr dense onClose={() => { setOpen(false) }} items={[ + { id: 'copy', label: t('menu.copyMarkdown'), icon: , disabled: copying }, { id: 'download', label: t('menu.download'), icon: , disabled: busy }, ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], ]} onSelect={(id) => { setOpen(false) if (id === 'feedback') openFeedback(sessionId) + else if (id === 'copy') void copyMarkdown(sessionId) else void request(sessionId) }} anchor={( diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts new file mode 100644 index 0000000000..effb39e190 --- /dev/null +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -0,0 +1,121 @@ +/** + * Browser controller for the Session Header's "Copy as Markdown" action. One + * plugin instance owns one pending copy: the clipboard is a browser-wide + * destination, so a second request joins the pending operation instead of + * racing it. The Header disables its copy row from this state; the toast lives + * in a separate `shell.overlay` entry so it survives the menu closing or the + * Session changing. + * @module + */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import { SESSION_MARKDOWN_EXPORT_ROUTE } from '../routes.ts' + +/** Whether the latest copy attempt succeeded or failed. */ +export type SessionMarkdownCopyNoticeKind = 'success' | 'failed' + +/** One settled copy notice; `seq` keys the toast so a repeat copy restarts it. */ +export interface SessionMarkdownCopyNotice { + readonly kind: SessionMarkdownCopyNoticeKind + readonly seq: number +} + +/** Copy state shared by every Header row and the root overlay toast. */ +export interface SessionMarkdownCopyState { + /** Whether this plugin instance has a copy operation still pending. */ + copying: boolean + /** Latest settled copy notice, or null while none shows. */ + notice: SessionMarkdownCopyNotice | null +} + +/** HTTP carrier for the Markdown route. */ +type Fetch = (input: string | URL, init?: RequestInit) => Promise +/** Host clipboard write; false means the browser rejected it. */ +type Write = (text: string) => Promise + +const INITIAL: SessionMarkdownCopyState = { copying: false, notice: null } + +/** Owns the one pending Markdown copy for this plugin instance. */ +export class SessionMarkdownCopyController { + /** uSES-safe state source shared by every Header row and the overlay toast. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + private pending: { readonly abort: AbortController; readonly done: Promise } | undefined + private disposed = false + private noticeSeq = 0 + + constructor( + private readonly fetcher: Fetch = (input, init) => fetch(input, init), + private readonly write: Write = writeClipboard, + ) {} + + /** + * Copy one Session's complete transcript. A request received while a copy is + * pending joins that operation and keeps its originally captured Session. + * @param sessionId - Session captured by the click. + * @returns after the clipboard write settles, a failure notice is published, or a late post-disposal request is ignored. + */ + copy(sessionId: SessionId): Promise { + if (this.disposed) return Promise.resolve() + if (this.pending !== undefined) return this.pending.done + const abort = new AbortController() + const done = this.run(sessionId, abort.signal).finally(() => { + this.pending = undefined + }) + this.pending = { abort, done } + return done + } + + /** Clear the shown toast without affecting a pending copy. */ + dismiss(): void { + if (this.store.getSnapshot().notice === null) return + this.store.update((state) => { state.notice = null }) + } + + /** + * Abort a pending fetch, suppress a late clipboard write or notice, and + * reach quiescence. + * @returns after the pending operation settles. + */ + async dispose(): Promise { + this.disposed = true + const pending = this.pending + pending?.abort.abort() + await pending?.done + this.store.set(INITIAL) + } + + private async run(sessionId: SessionId, signal: AbortSignal): Promise { + this.store.update((state) => { state.copying = true }) + try { + const query = new URLSearchParams({ sessionId }) + const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { signal }) + if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) + const markdown = await response.text() + // The plugin may unload while the fetch or a clipboard call is pending; + // never write or report after disposal. + if (this.isCancelled()) return + if (!await this.write(markdown)) throw new Error('the browser rejected the clipboard write') + if (this.isCancelled()) return + this.publishNotice('success') + } catch { + if (this.isCancelled()) return + this.publishNotice('failed') + } finally { + this.store.update((state) => { state.copying = false }) + } + } + + private isCancelled(): boolean { + return this.disposed + } + + private publishNotice(kind: SessionMarkdownCopyNoticeKind): void { + this.store.update((state) => { + state.notice = { kind, seq: ++this.noticeSeq } + }) + } +} diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index bcba0dd581..cf97b268c2 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -10,6 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from './controller.ts' +import { SessionMarkdownCopyController } from './copy-controller.ts' +import { SessionMarkdownCopyToast, type SessionMarkdownCopyToastInjected } from './CopyToast.tsx' import { SessionLogDownloadHeaderAction, type SessionLogDownloadHeaderInjected } from './HeaderAction.tsx' import { en, NS, zh, type SessionLogDownloadKey } from './locales.ts' @@ -30,13 +32,16 @@ export type { SessionLogDownloadEntry, SessionLogDownloadState } from './control export const inject = ['slots', 'locale'] /** - * Provide the download controller and mount its modal into the Session Header. + * Provide the download controller and mount its modal, the Markdown copy + * controller and its root toast, and both Header actions. * @param ctx - browser context carrying slots and locale services. */ export function apply(ctx: ClientContext): void { const controller = new SessionLogDownloadController() + const copyController = new SessionMarkdownCopyController() ctx.provide('sessionLogDownload', controller) ctx.effect(() => async () => { await controller.dispose() }, 'session-log-download: browser download lifecycle') + ctx.effect(() => async () => { await copyController.dispose() }, 'session-log-download: browser Markdown copy lifecycle') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-log-download: browser dictionaries') const feedbackAvailable = createSnapshotStore(false) ctx.inject(['feedbackUi'], (scope: ClientContext) => { @@ -48,13 +53,27 @@ export function apply(ctx: ClientContext): void { ctx.on('command/executed', (sessionId, commandName, result) => { if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId) }) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'session-log-download.copy-toast', + locale: NS, + inject: (): SessionMarkdownCopyToastInjected => ({ + hooks: { sessionMarkdownCopy: copyController.store }, + dismiss: () => { copyController.dismiss() }, + }), + }, SessionMarkdownCopyToast)) ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'session-log-download', locale: NS, inject: (): SessionLogDownloadHeaderInjected => ({ - hooks: { sessionLogDownload: controller.store, feedbackAvailable }, + hooks: { + sessionLogDownload: controller.store, + sessionMarkdownCopy: copyController.store, + feedbackAvailable, + }, request: (sessionId: SessionId) => controller.download(sessionId), + copyMarkdown: (sessionId: SessionId) => copyController.copy(sessionId), dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, // The feedback plugin can unload between the menu render and this click. openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) }, diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 1fd687a379..86d37f125a 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -4,8 +4,11 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { 'header.more': '更多操作', + 'menu.copyMarkdown': '复制为 Markdown', 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', + 'toast.copied': 'Session 已复制为 Markdown', + 'toast.copyFailed': '无法复制 Session,请检查剪贴板权限后重试', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -18,8 +21,11 @@ export const zh = { /** English Session export strings. */ export const en: Record = { 'header.more': 'More actions', + 'menu.copyMarkdown': 'Copy as Markdown', 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', + 'toast.copied': 'Session copied as Markdown.', + 'toast.copyFailed': 'Could not copy the Session. Check clipboard access and try again.', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 98870d0618..900aadf469 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -17,7 +17,8 @@ import { type SessionLogCompressionLevel, type SessionLogExportReady, } from './archive.ts' -import { SESSION_LOG_EXPORT_PATH } from './routes.ts' +import { formatSessionMarkdown } from './markdown.ts' +import { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -40,7 +41,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -export { SESSION_LOG_EXPORT_PATH } from './routes.ts' +export { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { @@ -99,12 +100,74 @@ export function apply(ctx: Context, config: Config = {}): void { return new Response(null, { status: response.status, headers: response.headers }) }, }) + connectionOf(ctx).fetch.register({ + path: SESSION_MARKDOWN_EXPORT_PATH, + methods: ['GET', 'HEAD'], + requestBody: 'buffered', + fetch: async (request) => { + const response = await sessionMarkdownResponse(ctx, request) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) + }, + }) } function connectionOf(ctx: Context): SessionLogConnection { return Reflect.get(ctx, 'connection') as SessionLogConnection } +/** + * Produce one complete Session transcript as Markdown. The read is a detached + * export observation through session-query's live-preferred corpus, so it + * creates no Agent, retains no Session, and writes no Session data. Abort is + * observed before and after each read; the corpus read itself has no + * cancellation parameter. + * @param ctx - host context carrying Connection and session-query. + * @param request - authenticated Fetch request with a required `sessionId` query. + * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. + */ +async function sessionMarkdownResponse(ctx: Context, request: Request): Promise { + const sessionIdValue = new URL(request.url).searchParams.get('sessionId') + if (sessionIdValue === null || sessionIdValue.length === 0) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + const sessionQuery = ctx.get('sessionQuery') + if (sessionQuery === undefined) { + return new Response('session markdown export is unavailable: missing session-query service', { status: 500 }) + } + const sessionId = brandString(sessionIdValue) + try { + request.signal.throwIfAborted() + const snapshot = await sessionQuery.readSession(sessionId) + request.signal.throwIfAborted() + const title = await sessionQuery.readTitle(sessionId, request.signal) + request.signal.throwIfAborted() + return new Response(formatSessionMarkdown({ title: title?.title, events: snapshot.events }), { + headers: { 'content-type': 'text/markdown; charset=utf-8' }, + }) + } catch (error: unknown) { + request.signal.throwIfAborted() + // Absence is session-query's decision: match its stable error code rather + // than adding a Host value dependency on the session-query error class. + if (isSessionNotFound(error)) return new Response('session not found', { status: 404 }) + // Every other failure stays opaque: the message may carry absolute host + // paths into the browser toast. + return new Response('session markdown export failed to read the stored log', { status: 500 }) + } +} + +/** + * Identify the session-query "not found" failure by its stable machine code. + * @param error - an arbitrary rejection from the query service. + * @returns whether the error reports a missing Session. + */ +function isSessionNotFound(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' +} + async function sessionLogExportResponse( ctx: Context, request: Request, diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts new file mode 100644 index 0000000000..fa5ba7b433 --- /dev/null +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -0,0 +1,302 @@ +/** + * Renders one complete committed Session log as Markdown for the "Copy as + * Markdown" action. The raw log is authoritative: compaction surface + * replacements never remove earlier messages, and fork-inherited history + * appears once. Reasoning, system and developer messages, injected non-human + * context, request headers, and runtime records are omitted. + * @module + */ + +import type { + AssistantMessage, + SessionEvent, + ToolResultMessage, + UserMessage, +} from '@deepseek-ai/dsh-session' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' + +/** Heading used when the log carries no title and the Session has no name. */ +export const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' + +/** Input to {@link formatSessionMarkdown}. */ +export interface SessionMarkdownInput { + /** Latest log-backed title, or `undefined` when the log has no title event. */ + readonly title: string | undefined + /** Complete committed raw event log in seq order, including inherited history. */ + readonly events: readonly SessionEvent[] +} + +interface AssistantEntry { + readonly kind: 'assistant' + readonly text: string + readonly toolCalls: boolean +} + +interface ToolEntry { + readonly kind: 'tool' + readonly name: string + readonly arguments: string + result: ToolResult | undefined +} + +interface ToolResult { + readonly text: string + readonly isError: boolean + readonly reason: string | undefined +} + +interface UserSegment { + readonly kind: 'user' + readonly text: string +} + +type TurnEntry = AssistantEntry | ToolEntry +type TurnSegment = UserSegment | TurnEntry + +interface Turn { + readonly segments: TurnSegment[] + readonly calls: Map +} + +/** + * Format one complete Session log as Markdown. + * @param input - the latest title and the complete committed event log. + * @returns Markdown ending in exactly one newline; an empty Session returns its title heading only. + */ +export function formatSessionMarkdown(input: SessionMarkdownInput): string { + const sections = [`# ${normalizedTitle(input.title)}`] + for (const turn of collectTurns(input.events)) { + const finalIndex = finalReplyIndex(turn.segments) + const end = finalIndex < 0 ? turn.segments.length : finalIndex + let pending: TurnEntry[] = [] + for (const segment of turn.segments.slice(0, end)) { + if (segment.kind === 'user') { + if (pending.length > 0) { + sections.push(collapsibleSection(pending)) + pending = [] + } + sections.push(quote(segment.text)) + } else { + pending.push(segment) + } + } + if (pending.length > 0) sections.push(collapsibleSection(pending)) + const final = turn.segments[finalIndex] + const finalText = final?.kind === 'assistant' ? final.text.trim() : '' + if (finalText !== '') sections.push(finalText) + } + return `${sections.join('\n\n').trimEnd()}\n` +} + +function normalizedTitle(title: string | undefined): string { + const line = title?.replace(/\r\n?|\n/gu, ' ').trim() ?? '' + return line === '' ? UNTITLED_SESSION_MARKDOWN_TITLE : line +} + +function collectTurns(events: readonly SessionEvent[]): Turn[] { + const turns: Turn[] = [] + let current: Turn | undefined + const begin = (): Turn => { + current = { segments: [], calls: new Map() } + turns.push(current) + return current + } + const active = (): Turn => current ?? begin() + for (const event of events) { + switch (event.type) { + case 'turn/start': + begin() + break + case 'turn/end': + current = undefined + break + case 'user/message': { + if (event.surfaceOp !== 'append' || event.data.source.kind !== 'user') break + const text = visibleContent(event.data.content) + if (text !== '') active().segments.push({ kind: 'user', text }) + break + } + case 'assistant/message': { + if (event.surfaceOp !== 'append') break + const message = event.data.message + const text = assistantText(message) + const toolCalls = message.content.some(block => block.type === 'tool-call') + if (text.trim() !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) + break + } + case 'tool/call': { + const turn = active() + const entry: ToolEntry = { + kind: 'tool', + name: event.data.name, + arguments: event.data.arguments, + result: undefined, + } + turn.segments.push(entry) + turn.calls.set(String(event.data.callId), entry) + break + } + case 'tool/result': { + if (event.surfaceOp !== 'append') break + const turn = active() + const callId = String(event.data.message.toolCallId) + let entry = turn.calls.get(callId) + if (entry === undefined) { + entry = { kind: 'tool', name: 'unknown', arguments: '', result: undefined } + turn.segments.push(entry) + turn.calls.set(callId, entry) + } + entry.result = toolResult(event.data) + break + } + default: + break + } + } + return turns.filter(turn => turn.segments.length > 0) +} + +function finalReplyIndex(segments: readonly TurnSegment[]): number { + const index = segments.length - 1 + const entry = segments[index] + return entry?.kind === 'assistant' && !entry.toolCalls ? index : -1 +} + +function collapsibleSection(entries: readonly TurnEntry[]): string { + const summary = entries.length === 1 ? '1 previous message' : `${String(entries.length)} previous messages` + const body = entries.map(renderEntry).join('\n\n') + return `
${summary}\n\n${body}\n\n
` +} + +function renderEntry(entry: TurnEntry): string { + if (entry.kind === 'assistant') { + return `**Assistant**\n\n${escapeWrapperTagsOutsideFences(entry.text.trim())}` + } + const heading = `**Tool: \`${escapeWrapperTags(entry.name)}\`**` + const argumentsText = entry.arguments === '' + ? '(no arguments)' + : fenced(jsonArguments(entry.arguments), 'json') + const result = entry.result + const resultHeading = result?.isError === true ? '**Result (error)**' : '**Result**' + let resultBody = '' + if (result === undefined) { + resultBody = '(no result recorded)' + } else if (result.reason !== undefined && result.reason !== '') { + resultBody = `Error: ${escapeWrapperTags(result.reason)}` + if (result.text !== '') resultBody += `\n\n${fenced(result.text)}` + } else if (result.text === '') { + resultBody = '(no output)' + } else { + resultBody = fenced(result.text) + } + return `${heading}\n\n${argumentsText}\n\n${resultHeading}\n\n${resultBody}` +} + +function visibleContent(content: UserMessage['content']): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + if (block.text !== '') parts.push(block.text) + break + case 'image': + parts.push(imageLabel(block.attachment)) + break + case 'file': + parts.push(fileLabel(block.attachment)) + break + default: + break + } + } + return parts.join('\n\n') +} + +function assistantText(message: AssistantMessage): string { + return visibleContent(message.content) +} + +function toolResult(data: { + readonly message: ToolResultMessage + readonly error?: { readonly name: string; readonly code: string; readonly reason?: string } +}): ToolResult { + return { + text: visibleContent(data.message.content), + isError: data.message.isError === true, + reason: data.error?.reason, + } +} + +function quote(text: string): string { + const normalized = text.replace(/\r\n?/gu, '\n').replace(/\n+$/u, '') + return normalized.split('\n').map(line => line === '' ? '>' : `> ${line}`).join('\n') +} + +function imageLabel(ref: ImageAttachmentRef): string { + return `[Image: ${labelName(ref.name)} (${String(ref.width)}×${String(ref.height)}, ${String(ref.bytes)} bytes)]` +} + +function fileLabel(ref: FileAttachmentRef): string { + return `[File: ${labelName(ref.name)} (${String(ref.bytes)} bytes)]` +} + +function labelName(name: string | undefined): string { + const normalized = name?.replace(/\s+/gu, ' ').replace(/\]/gu, '\\]').trim() ?? '' + return normalized === '' ? 'attachment' : normalized +} + +function jsonArguments(argumentsText: string): string { + let parsed: unknown + try { + parsed = JSON.parse(argumentsText) + } catch { + return argumentsText + } + return JSON.stringify(parsed, null, 2) +} + +function fenced(text: string, language = ''): string { + const content = text.replace(/\n+$/u, '') + let longest = 0 + for (const match of content.matchAll(/`+/gu)) longest = Math.max(longest, match[0].length) + const fence = '`'.repeat(Math.max(3, longest + 1)) + return `${fence}${language}\n${content}\n${fence}` +} + +// Arbitrary commentary inside a generated details section must not close the +// wrapper; fenced code blocks keep their exact text because their backtick +// fence already prevents HTML parsing. +function escapeWrapperTagsOutsideFences(markdown: string): string { + const lines = markdown.replace(/\r\n?/gu, '\n').split('\n') + let fenceCharacter: string | undefined + let fenceLength = 0 + const output: string[] = [] + for (const line of lines) { + if (fenceCharacter === undefined) { + const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) + if (opening !== null) { + const marker = String(opening[1]) + const info = String(opening[2]) + if (marker.startsWith('~') || !info.includes('`')) { + fenceCharacter = marker[0] + fenceLength = marker.length + output.push(line) + continue + } + } + output.push(escapeWrapperTags(line)) + continue + } + const closing = new RegExp(`^ {0,3}${fenceCharacter}{${String(fenceLength)},}\\s*$`, 'u') + if (closing.test(line)) { + fenceCharacter = undefined + fenceLength = 0 + } + output.push(line) + } + return output.join('\n') +} + +function escapeWrapperTags(text: string): string { + return text.replace(/<(?=\/?(?:details|summary)(?=[\s/>]))/giu, '<') +} diff --git a/packages/session-query/session-log-export/src/routes.ts b/packages/session-query/session-log-export/src/routes.ts index e603351995..9df9224466 100644 --- a/packages/session-query/session-log-export/src/routes.ts +++ b/packages/session-query/session-log-export/src/routes.ts @@ -1,6 +1,6 @@ /** - * The absolute pathname the Host registers the export under and the - * document-relative form the browser addresses; see + * The absolute pathnames the Host registers the export routes under and the + * document-relative forms the browser addresses; see * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. */ @@ -9,3 +9,9 @@ export const SESSION_LOG_EXPORT_PATH = '/api/session.export' /** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */ export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1) + +/** Absolute registration path for the Markdown copy route. */ +export const SESSION_MARKDOWN_EXPORT_PATH = '/api/session.markdown' + +/** Browser-relative form of {@link SESSION_MARKDOWN_EXPORT_PATH}. */ +export const SESSION_MARKDOWN_EXPORT_ROUTE = SESSION_MARKDOWN_EXPORT_PATH.slice(1) diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index caaf8a4e95..d481acf6bd 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -4,13 +4,18 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' +import type { SessionMarkdownCopyToastInjected } from '../src/client/CopyToast.tsx' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import { apply, inject } from '../src/client/index.ts' import type { SessionLogDownloadHeaderInjected } from '../src/client/HeaderAction.tsx' const SID = 'session-export-apply' as SessionId -afterEach(() => { vi.unstubAllGlobals() }) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) function declare(slots: SlotRegistry): () => void { return slots.register({ @@ -18,6 +23,7 @@ function declare(slots: SlotRegistry): () => void { children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, }, } as never, () => null) } @@ -54,7 +60,7 @@ describe('session-log-download browser plugin', () => { await b.fiber.dispose() }) - it('provides one controller and removes its Header contribution on disposal', async () => { + it('provides one controller and removes its Header and overlay contributions on disposal', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() expect(inject).toEqual(['slots', 'locale']) @@ -63,14 +69,24 @@ describe('session-log-download browser plugin', () => { const entry = b.slots.entries('conversation.session.header.utilities')[0] expect(entry?.component).toBe(SessionLogDownloadHeaderAction) expect(entry?.options).toMatchObject({ id: 'session-log-download' }) - const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionLogDownloadDialogInjected)() + const injected = (entry?.inject as unknown as () => SessionLogDownloadHeaderInjected)() await injected.request(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.open).toBe(false) + const overlay = b.slots.entries('shell.overlay')[0] + expect(overlay?.component).toBe(SessionMarkdownCopyToast) + expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) + const overlayInjected = (overlay?.inject as unknown as () => SessionMarkdownCopyToastInjected)() + await injected.copyMarkdown(SID) + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') + overlayInjected.dismiss() + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() + await b.fiber.dispose() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) }) it('downloads only for an export execution acknowledged by this browser client', async () => { @@ -98,9 +114,11 @@ describe('session-log-download browser plugin', () => { const b = await bench() b.declaration() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) const redeclare = declare(b.slots) await Promise.resolve() expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionLogDownloadHeaderAction) + expect(b.slots.entries('shell.overlay')[0]?.component).toBe(SessionMarkdownCopyToast) redeclare() await b.fiber.dispose() }) diff --git a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts new file mode 100644 index 0000000000..bbcaa27711 --- /dev/null +++ b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' + +const SID = 'session-copy-controller' as SessionId +const OTHER = 'session-copy-other' as SessionId + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('SessionMarkdownCopyController', () => { + it('fetches the Markdown route and writes the complete response', async () => { + const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('# Title\n\nText\n', { status: 200 })) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + await controller.copy(SID) + + expect(fetcher).toHaveBeenCalledOnce() + const [route, init] = fetcher.mock.calls[0]! + expect(route).toBe(`api/session.markdown?sessionId=${SID}`) + expect(init?.method).toBeUndefined() + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect(write).toHaveBeenCalledWith('# Title\n\nText\n') + expect(controller.store.getSnapshot()).toMatchObject({ + copying: false, + notice: { kind: 'success' }, + }) + }) + + it('joins a second-Session request to the pending copy and keeps its captured target', async () => { + const responses = Promise.withResolvers() + const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => responses.promise) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + const first = controller.copy(SID) + const second = controller.copy(OTHER) + expect(first).toBe(second) + expect(controller.store.getSnapshot().copying).toBe(true) + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.markdown?sessionId=${SID}`) + + responses.resolve(new Response('markdown for first')) + await first + expect(write).toHaveBeenCalledOnce() + expect(write).toHaveBeenCalledWith('markdown for first') + expect(controller.store.getSnapshot().copying).toBe(false) + + await controller.copy(OTHER) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetcher.mock.calls[1]?.[0]).toBe(`api/session.markdown?sessionId=${OTHER}`) + }) + + it('publishes failures for HTTP, transport, and clipboard rejection without leaking rejections', async () => { + const http = new SessionMarkdownCopyController( + async () => new Response('hidden detail', { status: 500 }), + vi.fn(async () => true), + ) + await http.copy(SID) + expect(http.store.getSnapshot()).toMatchObject({ + copying: false, + notice: { kind: 'failed' }, + }) + + const write = vi.fn() + const transport = new SessionMarkdownCopyController(async () => { throw new Error('offline') }, write) + await transport.copy(SID) + expect(transport.store.getSnapshot().notice?.kind).toBe('failed') + expect(write).not.toHaveBeenCalled() + + const rejected = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => false, + ) + await rejected.copy(SID) + expect(rejected.store.getSnapshot().notice?.kind).toBe('failed') + }) + + it('accepts a new copy after a failed operation settles', async () => { + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response('hidden', { status: 500 })) + .mockResolvedValueOnce(new Response('markdown')) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('failed') + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + expect(fetcher).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() + }) + + it('dismisses a notice and ignores a dismissal with no notice', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => true, + ) + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + }) + + it('aborts pending fetches and suppresses a write that settles after disposal', async () => { + const gate = Promise.withResolvers() + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(() => gate.promise, write) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + gate.resolve(new Response('late markdown')) + await disposing + await pending + + expect(write).not.toHaveBeenCalled() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + await expect(controller.copy(SID)).resolves.toBeUndefined() + }) + + it('suppresses success when disposal starts while the clipboard write is pending', async () => { + const writeGate = Promise.withResolvers() + const write = vi.fn(() => writeGate.promise) + const controller = new SessionMarkdownCopyController(async () => new Response('markdown'), write) + const pending = controller.copy(SID) + await vi.waitFor(() => { expect(write).toHaveBeenCalledOnce() }) + + const disposing = controller.dispose() + writeGate.resolve(true) + await disposing + await pending + + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('suppresses a rejection caused by disposal instead of publishing failure', async () => { + const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + })) + const controller = new SessionMarkdownCopyController(fetcher, vi.fn(async () => true)) + const pending = controller.copy(SID) + + await controller.dispose() + await pending + + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('uses the document-relative default carrier and the host clipboard writer', async () => { + const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('default markdown')) + const writeText = vi.fn(async () => {}) + vi.stubGlobal('fetch', fetcher) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const controller = new SessionMarkdownCopyController() + + await controller.copy(SID) + + expect(fetcher).toHaveBeenCalledOnce() + const [route, init] = fetcher.mock.calls[0]! + expect(route).toBe(`api/session.markdown?sessionId=${SID}`) + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect(writeText).toHaveBeenCalledWith('default markdown') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) +}) diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx new file mode 100644 index 0000000000..f03b5e7702 --- /dev/null +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useSyncExternalStore } from 'react' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionMarkdownCopyState } from '../src/client/copy-controller.ts' +import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' +import type { SessionMarkdownCopyToastProps } from '../src/client/CopyToast.tsx' +import { en } from '../src/client/locales.ts' + +function bindSnapshot(store: ObservableSnapshot) { + return function useSessionMarkdownCopy(selector: (state: SessionMarkdownCopyState) => T): T { + return useSyncExternalStore( + listener => store.subscribe(listener), + () => selector(store.getSnapshot()), + ) + } +} + +function bench(notice: SessionMarkdownCopyState['notice']) { + const store = createSnapshotStore({ copying: false, notice }) + const dismiss = vi.fn() + const props = { + useSessionMarkdownCopy: bindSnapshot(store), + dismiss, + t: (key: keyof typeof en): string => en[key], + } as unknown as SessionMarkdownCopyToastProps + return { ...render(), store, dismiss } +} + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('Session Markdown copy toast', () => { + it('shows the success outcome and dismisses after the banner completes', () => { + vi.useFakeTimers() + const b = bench({ kind: 'success', seq: 1 }) + expect(b.getByRole('alert').textContent).toBe('Session copied as Markdown.') + act(() => { vi.advanceTimersByTime(4_000) }) + expect(b.dismiss).toHaveBeenCalledOnce() + }) + + it('shows the failure outcome with its actionable copy', () => { + const b = bench({ kind: 'failed', seq: 2 }) + expect(b.getByRole('alert').textContent).toBe( + 'Could not copy the Session. Check clipboard access and try again.', + ) + }) + + it('renders nothing while no notice shows', () => { + const b = bench(null) + expect(b.container.childElementCount).toBe(0) + }) +}) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index dc0da23e36..e3d6499439 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' +import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' @@ -20,24 +21,31 @@ function bindSnapshot(store: ObservableSnapshot) { } } -function bench(feedbackAvailable = false) { +function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => true, +)) { const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) + const copyMarkdown = vi.fn((sessionId: SessionId) => copyController.copy(sessionId)) const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) const openFeedback = vi.fn() const feedback = createSnapshotStore(feedbackAvailable) const useSessionLogDownload = bindSnapshot(controller.store) + const useSessionMarkdownCopy = bindSnapshot(copyController.store) const props = { sessionId: SID, useSessionLogDownload, + useSessionMarkdownCopy, useFeedbackAvailable: bindSnapshot(feedback), openFeedback, request, + copyMarkdown, dismiss, t: (key: keyof typeof en): string => en[key], } as unknown as SessionLogDownloadHeaderProps const view = render() - return { controller, request, openFeedback, feedback, view } + return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } } afterEach(cleanup) @@ -56,6 +64,7 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() + expect(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })).toBeTruthy() expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() }) @@ -77,6 +86,42 @@ describe('Session export Header action', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('copies Markdown from the menu and closes it without starting a download', async () => { + const b = bench() + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + 'Copy as Markdown', + 'Download session log', + ]) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })) + await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) + expect(b.request).not.toHaveBeenCalled() + expect(b.view.queryByRole('menu')).toBeNull() + expect(b.copyController.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('disables only the copy row while a copy is pending', async () => { + const gate = Promise.withResolvers() + const copyController = new SessionMarkdownCopyController(() => gate.promise, async () => true) + const b = bench(true, copyController) + const pending = copyController.copy(SID) + expect(copyController.store.getSnapshot().copying).toBe(true) + + const button = b.view.getByRole('button', { name: 'More actions' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + const copyItem = b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) + await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) + expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) + expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) + expect(button.getAttribute('aria-busy')).toBe('false') + + gate.resolve(new Response('markdown')) + await pending + expect(b.copyController.store.getSnapshot().copying).toBe(false) + await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) + }) + it('opens the more-actions menu and downloads through the shared controller', async () => { const b = bench() const button = b.view.getByRole('button', { name: 'More actions' }) @@ -108,9 +153,11 @@ describe('Session export Header action', () => { b.view.rerender( controller.download(sessionId), + copyMarkdown: b.copyMarkdown, dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, t: (key: keyof typeof en): string => en[key], } as unknown as SessionLogDownloadHeaderProps)} />) @@ -121,6 +168,7 @@ describe('Session export Header action', () => { fireEvent.click(button) const item = b.view.getByRole('menuitem', { name: 'Download session log' }) expect((item as HTMLButtonElement).disabled).toBe(true) + expect((b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) release(new Response('zip')) await download diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts new file mode 100644 index 0000000000..3abfa2e855 --- /dev/null +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -0,0 +1,176 @@ +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' +import { describe, expect, it } from 'vitest' +import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' + +const SESSION_ID = 'session-markdown-route' as SessionId + +const HEADER: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SESSION_ID, + createdAt: 1, + isSeeded: false, + cwd: '/workspace', + delegationDepth: 0, +} + +const EVENTS: SessionEvent[] = [ + { type: 'turn/start', seq: brandNumber(1), time: 1, data: { turn: 1 } }, + { + type: 'user/message', + seq: brandNumber(2), + time: 2, + surfaceOp: 'append', + data: { + role: 'user', + id: brandString('message-1'), + source: { kind: 'user' }, + content: [{ type: 'text', text: 'Route question' }], + }, + }, + { + type: 'assistant/message', + seq: brandNumber(3), + time: 3, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'assistant', + id: brandString('message-2'), + source: { kind: 'model', provider: 'test', model: 'test' }, + content: [{ type: 'text', text: 'Route answer' }], + }, + stream: [], + }, + }, + { type: 'turn/end', seq: brandNumber(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, +] + +function missingSessionError(): Error { + return Object.assign(new Error('not found'), { code: 'SESSION_QUERY_SESSION_NOT_FOUND' }) +} + +function query(overrides: { + readonly readSession?: (id: SessionId) => Promise + readonly readTitle?: (id: SessionId, signal?: AbortSignal) => Promise +} = {}): unknown { + return { + readSession: overrides.readSession + ?? (async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })), + readTitle: overrides.readTitle + ?? (async () => ({ + title: 'Route title', messageSeqs: [], source: { kind: 'fallback' }, eventSeq: 2, updatedAt: 2, + })), + } +} + +async function mounted(sessionQuery: unknown): Promise<{ + readonly shared: ReturnType + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + if (sessionQuery !== undefined) ctx.provide('sessionQuery', sessionQuery as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { + shared: connection.createSharedFetchHandler('/api'), + connection, + dispose: () => fiber.dispose(), + } +} + +function request(path: string, init?: RequestInit): Request { + return new Request(`http://host${path}`, init) +} + +describe('Session Markdown Fetch route', () => { + it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { + const { shared, connection, dispose } = await mounted(query()) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + const markdown = await response.text() + expect(markdown).toContain('# Route title') + expect(markdown).toContain('> Route question') + expect(markdown).toContain('Route answer') + expect(markdown.endsWith('\n')).toBe(true) + + const head = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`, { method: 'HEAD' }), + connection.browserPeer, + ) + expect(head.status).toBe(200) + expect(head.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + expect(head.body).toBeNull() + + await dispose() + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + )).status).toBe(404) + }) + + it('validates the query before reporting missing Markdown services', async () => { + const { shared, connection, dispose } = await mounted(undefined) + expect((await shared.fetch(request(SESSION_MARKDOWN_EXPORT_PATH), connection.browserPeer)).status).toBe(400) + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=`), + connection.browserPeer, + )).status).toBe(400) + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + )).status).toBe(500) + await dispose() + }) + + it('maps a missing Session to 404 and sanitizes every other read failure', async () => { + const missing = await mounted(query({ readSession: async () => { throw missingSessionError() } })) + const missingResponse = await missing.shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + missing.connection.browserPeer, + ) + expect(missingResponse.status).toBe(404) + expect(await missingResponse.text()).toBe('session not found') + await missing.dispose() + + const broken = await mounted(query({ + readSession: async () => { throw new Error('failed at /private/host/session.jsonl') }, + })) + const brokenResponse = await broken.shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + broken.connection.browserPeer, + ) + expect(brokenResponse.status).toBe(500) + const detail = await brokenResponse.text() + expect(detail).toBe('session markdown export failed to read the stored log') + expect(detail).not.toContain('/private/host') + await broken.dispose() + }) + + it('returns a sanitized 500 when the title read fails after the log read', async () => { + const { shared, connection, dispose } = await mounted(query({ + readTitle: async () => { throw new Error('title path /private/host') }, + })) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(500) + expect(await response.text()).toBe('session markdown export failed to read the stored log') + await dispose() + }) +}) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts new file mode 100644 index 0000000000..1b42768ca4 --- /dev/null +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -0,0 +1,508 @@ +import { describe, expect, it } from 'vitest' +import type { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock, MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' +import { formatSessionMarkdown } from '../src/markdown.ts' + +const messageId = (value: string): MessageId => brandString(value) +const callId = (value: string): ToolCallId => brandString(value) +const attachmentId = (value: string): AttachmentId => brandString(value) +const sequence = (value: number): SessionSeq => brandNumber(value) + +function start(seq: number, turn: number): SessionEvent<'turn/start'> { + return { type: 'turn/start', seq: sequence(seq), time: seq, data: { turn } } +} + +function end(seq: number, turn: number): SessionEvent<'turn/end'> { + return { type: 'turn/end', seq: sequence(seq), time: seq, data: { turn, reason: { kind: 'completed' } } } +} + +function user(seq: number, text: string, kind: 'user' | 'injected' = 'user'): SessionEvent<'user/message'> { + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + role: 'user', + id: messageId(`user-${String(seq)}`), + source: kind === 'user' ? { kind: 'user' } : { kind: 'model', provider: 'test', model: 'test' }, + content: [{ type: 'text', text }], + }, + } +} + +function userWithAttachments(seq: number): SessionEvent<'user/message'> { + const content: ContentBlock[] = [ + { type: 'text', text: 'See the attachments.' }, + { + type: 'image', + attachment: { + attachmentId: attachmentId('img-secret-1'), + mediaType: 'image/png', + bytes: 123, + width: 640, + height: 480, + name: 'diagram.png', + }, + }, + { + type: 'file', + attachment: { attachmentId: attachmentId('file-secret-1'), name: 'notes.txt', bytes: 42 }, + }, + ] + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { role: 'user', id: messageId(`user-${String(seq)}`), source: { kind: 'user' }, content }, + } +} + +function userBlocks(seq: number, content: ContentBlock[]): SessionEvent<'user/message'> { + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { role: 'user', id: messageId(`user-${String(seq)}`), source: { kind: 'user' }, content }, + } +} + +function assistant(seq: number, content: ContentBlock[], turn = 1): SessionEvent<'assistant/message'> { + return { + type: 'assistant/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + turn, + step: 1, + message: { + role: 'assistant', + id: messageId(`assistant-${String(seq)}`), + source: { kind: 'model', provider: 'test', model: 'test' }, + content, + }, + stream: [], + }, + } +} + +function toolCall(seq: number, id: string, name: string, argumentsText: string): SessionEvent<'tool/call'> { + return { + type: 'tool/call', + seq: sequence(seq), + time: seq, + data: { turn: 1, step: 1, callId: callId(id), name, arguments: argumentsText }, + } +} + +function toolResult( + seq: number, + id: string, + content: ContentBlock[], + options: { readonly isError?: boolean; readonly reason?: string } = {}, +): SessionEvent<'tool/result'> { + return { + type: 'tool/result', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'tool', + id: messageId(`result-${String(seq)}`), + source: { kind: 'tool', callId: callId(id) }, + toolCallId: callId(id), + content, + ...options.isError === true ? { isError: true } : {}, + }, + ...options.reason === undefined + ? {} + : { error: { name: 'ToolError', code: 'TOOL_FAILED', reason: options.reason } }, + }, + } +} + +function text(value: string): ContentBlock { + return { type: 'text', text: value } +} + +describe('formatSessionMarkdown', () => { + it('keeps complete chronology across inherited history and a compaction surface replacement', () => { + const markdown = formatSessionMarkdown({ + title: 'Chronology', + events: [ + start(1, 1), + user(2, 'Original question'), + assistant(3, [text('Original working note')]), + toolCall(4, 'call-1', 'read', '{"file_path":"a.md"}'), + toolResult(5, 'call-1', [text('alpha')]), + assistant(6, [text('Original answer')]), + end(7, 1), + { type: 'session/end-seed', seq: sequence(8), time: 8, data: {} }, + { + ...user(9, 'COMPACTED SUMMARY TEXT', 'injected'), + surfaceOp: { op: 'replace', startSeq: sequence(2), endSeq: sequence(6) }, + }, + start(10, 2), + user(11, 'Follow-up question'), + assistant(12, [text('Second answer')]), + end(13, 2), + ], + }) + + expect(markdown).toContain('> Original question') + expect(markdown).toContain('Original working note') + expect(markdown).toContain('Original answer') + expect(markdown).toContain('> Follow-up question') + expect(markdown).toContain('Second answer') + expect(markdown).not.toContain('COMPACTED SUMMARY TEXT') + expect(markdown.indexOf('Original answer')).toBeLessThan(markdown.indexOf('Second answer')) + expect(markdown.match(/Original answer/gu)).toHaveLength(1) + expect(markdown.endsWith('\n')).toBe(true) + expect(markdown.endsWith('\n\n')).toBe(false) + }) + + it('renders a turn with a collapsible commentary/tool section before the final reply', () => { + const markdown = formatSessionMarkdown({ + title: 'Turn layout', + events: [ + start(1, 1), + user(2, 'Please inspect'), + assistant(3, [text('I will inspect.')]), + toolCall(4, 'call-1', 'bash', '{"command":"echo hi"}'), + toolResult(5, 'call-1', [text('hi\n')]), + assistant(6, [text('Done.')]), + end(7, 1), + ], + }) + + expect(markdown).toBe([ + '# Turn layout', + '', + '> Please inspect', + '', + '
2 previous messages', + '', + '**Assistant**', + '', + 'I will inspect.', + '', + '**Tool: `bash`**', + '', + '```json', + '{', + ' "command": "echo hi"', + '}', + '```', + '', + '**Result**', + '', + '```', + 'hi', + '```', + '', + '
', + '', + 'Done.', + '', + ].join('\n')) + }) + + it('keeps a mid-turn steering message after the activity that preceded it', () => { + const markdown = formatSessionMarkdown({ + title: 'Steering order', + events: [ + start(1, 1), + user(2, 'Start the task'), + assistant(3, [text('Working before steering')]), + toolCall(4, 'call-1', 'bash', '{"command":"first"}'), + toolResult(5, 'call-1', [text('first result')]), + user(6, 'Steer to the second path'), + assistant(7, [text('Working after steering')]), + toolCall(8, 'call-2', 'bash', '{"command":"second"}'), + toolResult(9, 'call-2', [text('second result')]), + assistant(10, [text('Final answer')]), + end(11, 1), + ], + }) + + const before = markdown.indexOf('Working before steering') + const firstResult = markdown.indexOf('first result') + const steering = markdown.indexOf('> Steer to the second path') + const after = markdown.indexOf('Working after steering') + const secondResult = markdown.indexOf('second result') + const final = markdown.indexOf('Final answer') + expect(before).toBeGreaterThan(-1) + expect(before).toBeLessThan(firstResult) + expect(firstResult).toBeLessThan(steering) + expect(steering).toBeLessThan(after) + expect(after).toBeLessThan(secondResult) + expect(secondResult).toBeLessThan(final) + expect(markdown.match(/
/gu)).toHaveLength(2) + expect(markdown.match(/
2 previous messages<\/summary>/gu)).toHaveLength(2) + }) + + it('preserves authored details and code fences in the final reply', () => { + const authored = [ + 'Before', + '', + '
', + 'Authored', + '', + '```html', + 'code', + '```', + '', + '
', + '', + 'After', + ].join('\n') + const markdown = formatSessionMarkdown({ + title: 'Authored final', + events: [start(1, 1), user(2, 'Show details'), assistant(3, [text(authored)]), end(4, 1)], + }) + + expect(markdown).toContain(authored) + expect(markdown).not.toContain('<') + expect(markdown.endsWith(`${authored}\n`)).toBe(true) + }) + + it('ignores replacement surface events and keeps original append chronology', () => { + const markdown = formatSessionMarkdown({ + title: 'Replacements', + events: [ + start(1, 1), + user(2, 'Original prompt'), + assistant(3, [text('Original commentary')]), + toolCall(4, 'call-1', 'bash', '{"command":"original"}'), + toolResult(5, 'call-1', [text('original full output')]), + assistant(6, [text('Original final')]), + end(7, 1), + { + ...assistant(8, [text('REPLACEMENT ASSISTANT')]), + surfaceOp: { op: 'replace', startSeq: sequence(3), endSeq: sequence(6) }, + }, + { + ...toolResult(9, 'call-1', [text('REPLACEMENT TOOL OUTPUT')]), + surfaceOp: { op: 'replace', startSeq: sequence(5), endSeq: sequence(5) }, + }, + { + ...user(10, 'REPLACEMENT USER'), + surfaceOp: { op: 'replace', startSeq: sequence(2), endSeq: sequence(2) }, + }, + ], + }) + + expect(markdown).toContain('> Original prompt') + expect(markdown).toContain('Original commentary') + expect(markdown).toContain('original full output') + expect(markdown.match(/original full output/gu)).toHaveLength(1) + expect(markdown).toContain('Original final') + expect(markdown).not.toContain('REPLACEMENT ASSISTANT') + expect(markdown).not.toContain('REPLACEMENT TOOL OUTPUT') + expect(markdown).not.toContain('REPLACEMENT USER') + expect(markdown).not.toContain('**Tool: `unknown`**') + expect(markdown.match(/
/gu)).toHaveLength(1) + }) + + it('emits no empty assistant section and exactly one trailing newline', () => { + const markdown = formatSessionMarkdown({ + title: 'Whitespace', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text(' \n\t')]), + assistant(4, [text('Answer')]), + end(5, 1), + ], + }) + + expect(markdown).toBe('# Whitespace\n\n> Question\n\nAnswer\n') + }) + + it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { + const markdown = formatSessionMarkdown({ + title: 'Escaping', + events: [ + start(1, 1), + user(2, 'Run it'), + assistant(3, [text('Before\n\n
\n\n```md\n
\n```\n\n```bad`info')]), + toolCall(4, 'call-1', '', '{"value":"````"}'), + toolResult(5, 'call-1', [text('`````\n
')]), + assistant(6, [text('Finished')]), + end(7, 1), + ], + }) + + expect(markdown).toContain('</details>') + expect(markdown).toContain('```md\n
\n```') + expect(markdown).toContain('`````json') + expect(markdown).toContain('``````') + expect(markdown).toContain('**Tool: `</summary>`**') + expect(markdown.endsWith('\n\nFinished\n')).toBe(true) + }) + + it('labels attachments and omits reasoning, system prompts, and injected context', () => { + const markdown = formatSessionMarkdown({ + title: 'Hidden input', + events: [ + start(1, 1), + { + type: 'system/message', + seq: sequence(2), + time: 2, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'system', + id: messageId('system-1'), + source: { kind: 'system-prompt' }, + content: [text('SYSTEM ONLY')], + }, + }, + }, + userWithAttachments(3), + user(4, 'SECRET INJECTED', 'injected'), + assistant(5, [ + { type: 'reasoning', text: 'REASONING ONLY' }, + text('Visible answer'), + ]), + end(6, 1), + ], + }) + + expect(markdown).toContain('> See the attachments.') + expect(markdown).toContain('> [Image: diagram.png (640×480, 123 bytes)]') + expect(markdown).toContain('> [File: notes.txt (42 bytes)]') + expect(markdown).not.toContain('img-secret-1') + expect(markdown).not.toContain('file-secret-1') + expect(markdown).not.toContain('SECRET INJECTED') + expect(markdown).not.toContain('SYSTEM ONLY') + expect(markdown).not.toContain('REASONING ONLY') + expect(markdown).toContain('Visible answer') + }) + + it('exports only the heading for a blank Session and escapes title newlines', () => { + expect(formatSessionMarkdown({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') + expect(formatSessionMarkdown({ title: ' ', events: [] })).toBe('# Untitled Session\n') + expect(formatSessionMarkdown({ title: undefined, events: [] })).toBe('# Untitled Session\n') + }) + + it('renders tool-only and incomplete turns predictably', () => { + const markdown = formatSessionMarkdown({ + title: 'Incomplete', + events: [ + start(1, 1), + user(2, 'Run the tool'), + assistant(3, [{ type: 'tool-call', id: callId('call-1'), name: 'bash', arguments: '{}' }]), + toolCall(4, 'call-1', 'bash', '{}'), + toolResult(5, 'call-1', [text('done')], { isError: true, reason: 'exit 1' }), + start(6, 2), + user(7, 'Another'), + toolCall(8, 'call-2', 'read', '{"file_path":"b.md"}'), + toolResult(9, 'call-2', [{ type: 'image', attachment: { + attachmentId: attachmentId('result-image'), + mediaType: 'image/png', + bytes: 10, + width: 1, + height: 2, + } }]), + toolCall(10, 'call-3', 'bash', '{}'), + ], + }) + + expect(markdown).toContain('
1 previous message') + expect(markdown).toContain('**Result (error)**') + expect(markdown).toContain('Error: exit 1') + expect(markdown).toContain('[Image: attachment (1×2, 10 bytes)]') + expect(markdown).not.toContain('
0 previous messages') + expect(markdown).toContain('(no result recorded)') + expect(markdown.endsWith('\n')).toBe(true) + }) + + it('renders assistant-only, user-only, empty, and malformed content predictably', () => { + const markdown = formatSessionMarkdown({ + title: 'Edge content', + events: [ + // A committed assistant message before the first turn marker still + // renders, and forms an assistant-only turn with no user blockquote. + assistant(1, [text('No turn marker')]), + start(2, 1), + user(3, 'Only question'), + end(4, 1), + start(5, 2), + userBlocks(6, [ + text(''), + { type: 'tool-call', id: callId('user-tool'), name: 'read', arguments: '{}' }, + ]), + assistant(7, [ + text(''), + { + type: 'image', + attachment: { + attachmentId: attachmentId('assistant-image'), + mediaType: 'image/png', + bytes: 8, + width: 2, + height: 3, + }, + }, + { + type: 'file', + attachment: { attachmentId: attachmentId('assistant-file'), name: 'a.txt', bytes: 4 }, + }, + ]), + end(8, 2), + start(9, 3), + user(10, 'Tools'), + toolCall(11, 'call-1', 'bash', 'not-json'), + toolResult(12, 'call-1', [ + text(''), + { type: 'file', attachment: { attachmentId: attachmentId('result-file'), name: 'b.txt', bytes: 5 } }, + ]), + toolCall(13, 'call-2', 'bash', '{}'), + toolResult(14, 'call-2', [], { isError: true, reason: 'boom' }), + toolCall(15, 'call-3', 'bash', '{}'), + toolResult(16, 'call-3', []), + assistant(17, [text('Final')]), + end(18, 3), + ], + }) + + expect(markdown).toContain('No turn marker') + expect(markdown).toContain('> Only question') + expect(markdown).toContain('[Image: attachment (2×3, 8 bytes)]') + expect(markdown).toContain('[File: a.txt (4 bytes)]') + expect(markdown).toContain('[File: b.txt (5 bytes)]') + expect(markdown).toContain('not-json') + expect(markdown).toContain('Error: boom') + expect(markdown).toContain('(no output)') + expect(markdown).toContain('Final') + }) + + it('renders an orphan tool result without inventing a call name', () => { + const markdown = formatSessionMarkdown({ + title: 'Orphan', + events: [ + start(1, 1), + user(2, 'Question'), + toolResult(3, 'missing-call', [text('orphan output')]), + assistant(4, [text('Answer')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('**Tool: `unknown`**') + expect(markdown).toContain('orphan output') + expect(markdown).toContain('
1 previous message') + }) +}) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index 1735561ff3..c78dbf6893 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -6,9 +6,11 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ + "src/client/CopyToast.tsx", "src/client/Dialog.tsx", "src/client/HeaderAction.tsx", "src/client/controller.ts", + "src/client/copy-controller.ts", "src/client/index.ts", "src/client/locales.ts", "src/routes.ts", @@ -20,6 +22,7 @@ { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-conversation/tsconfig.client.json" }, + { "path": "../../client/ui-layout" }, { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 0832fbd1ac..22dad514a4 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -8,6 +8,7 @@ "files": [ "src/archive.ts", "src/index.ts", + "src/markdown.ts", "src/routes.ts" ], "references": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da43e12301..c24f6d915f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12258,6 +12258,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:* version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:* + version: link:../../client/ui-layout '@deepseek-ai/dsh-client-ui-message-feedback': specifier: workspace:* version: link:../../client/ui-message-feedback From 6118b51fa8e13d8a6d1c377df97286b2e36988d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:00:45 +0800 Subject: [PATCH 02/43] test(session-log-export): remove unknown casts from fixtures Type the Header and toast test fixtures with the standard Session/global props they render with, and read slot injections through a narrow object helper. Prune the four retired unknown-cast baseline entries. No product behavior changes. --- .../tests/client-apply.client.spec.tsx | 11 +++++-- .../tests/copy-toast.client.spec.tsx | 7 +++-- .../tests/header-action.client.spec.tsx | 30 ++++++++++++++----- scripts/no-unknown-casts.baseline.json | 8 ----- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index d481acf6bd..0613c9c9c2 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -28,6 +28,11 @@ function declare(slots: SlotRegistry): () => void { } as never, () => null) } +function injectedOf(entry: { inject?: ((...args: never[]) => object) | undefined } | undefined): object { + if (entry?.inject === undefined) throw new Error('slot entry has no injection') + return entry.inject() +} + async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() @@ -43,7 +48,7 @@ describe('session-log-download browser plugin', () => { it('follows feedback plugin availability and opens the selected Session form', async () => { const b = await bench() const entry = b.slots.entries('conversation.session.header.utilities')[0]! - const injected = (entry.inject as unknown as () => SessionLogDownloadHeaderInjected)() + const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) injected.openFeedback(SID) @@ -69,7 +74,7 @@ describe('session-log-download browser plugin', () => { const entry = b.slots.entries('conversation.session.header.utilities')[0] expect(entry?.component).toBe(SessionLogDownloadHeaderAction) expect(entry?.options).toMatchObject({ id: 'session-log-download' }) - const injected = (entry?.inject as unknown as () => SessionLogDownloadHeaderInjected)() + const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected await injected.request(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) @@ -78,7 +83,7 @@ describe('session-log-download browser plugin', () => { const overlay = b.slots.entries('shell.overlay')[0] expect(overlay?.component).toBe(SessionMarkdownCopyToast) expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) - const overlayInjected = (overlay?.inject as unknown as () => SessionMarkdownCopyToastInjected)() + const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected await injected.copyMarkdown(SID) expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') overlayInjected.dismiss() diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx index f03b5e7702..52a34b1a97 100644 --- a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -20,11 +20,12 @@ function bindSnapshot(store: ObservableSnapshot) { function bench(notice: SessionMarkdownCopyState['notice']) { const store = createSnapshotStore({ copying: false, notice }) const dismiss = vi.fn() - const props = { + const dictionary: Record = en + const props: SessionMarkdownCopyToastProps = { useSessionMarkdownCopy: bindSnapshot(store), dismiss, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionMarkdownCopyToastProps + t: key => dictionary[key] ?? key, + } return { ...render(), store, dismiss } } diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index e3d6499439..91400e6680 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' @@ -11,6 +12,18 @@ import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.t import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId +const dictionary: Record = en +const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key +const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } +const standard: GlobalStandardProps & SessionStandardProps = { + sessionId: SID, useSession: unused, useProjection: unused, useAccess: () => true, + useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, + useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, + useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused, + usePanelInfo: unused, useResource: unused, useWorkspaces: unused, + inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, + addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, +} function bindSnapshot(store: ObservableSnapshot) { return function useSnapshot(selector: (state: State) => T): T { @@ -33,8 +46,8 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo const feedback = createSnapshotStore(feedbackAvailable) const useSessionLogDownload = bindSnapshot(controller.store) const useSessionMarkdownCopy = bindSnapshot(copyController.store) - const props = { - sessionId: SID, + const props: SessionLogDownloadHeaderProps = { + ...standard, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable: bindSnapshot(feedback), @@ -42,8 +55,8 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo request, copyMarkdown, dismiss, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadHeaderProps + t: translate, + } const view = render() return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } } @@ -150,8 +163,8 @@ describe('Session export Header action', () => { const pending = new Promise((resolve) => { release = resolve }) const controller = new SessionLogDownloadController(() => pending, vi.fn()) const useSessionLogDownload = bindSnapshot(controller.store) - b.view.rerender( { request: (sessionId: SessionId) => controller.download(sessionId), copyMarkdown: b.copyMarkdown, dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadHeaderProps)} />) + t: translate, + } + b.view.rerender() const download = controller.download(SID) const button = b.view.getByRole('button', { name: 'More actions' }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 41520f4e27..9070545648 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1530,10 +1530,6 @@ "cb524acd0159b942e7974d76b581b1f419b8653d1734946a3f154dda8e8f4762": 1, "f10c6a0a3a762be54c2de87ab5d787e37a21a89c9d468bf81d0658c2bf15d581": 1 }, - "packages/session-query/session-log-export/tests/client-apply.client.spec.tsx": { - "04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1, - "eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 1 - }, "packages/session-query/session-log-export/tests/controller.client.spec.ts": { "746ebc5aade01a50cc9a74fc07b1023addf021aef80b62c069a437ac140c9e64": 1, "c920d88f6eb309ef5991608642a1ab0fd972690538b33c34d86c68d07ddd6edf": 1 @@ -1541,10 +1537,6 @@ "packages/session-query/session-log-export/tests/dialog.client.spec.tsx": { "a9b1969d76db0b05f87c862956f8ff45c52f1f4a5b32a206339de0c9fee99a77": 1 }, - "packages/session-query/session-log-export/tests/header-action.client.spec.tsx": { - "238a292bbe3a61acbc4f2d96fff474df5fba1577357219a88c914813786b56a7": 1, - "47d3a14a42ef7142ea22071677b42265f02fba5a2a0cf4210f4f9c7c5a876d10": 1 - }, "packages/session-query/session-log-export/tests/loader-composition.host.spec.ts": { "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1, "e12d49327a6708ae3ace371e28ed0deb471dc8895c584ba94a86af344c15cec3": 1 From 36c89de7bd38edd13a77b2e3cb466147a57c25b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:54 +0800 Subject: [PATCH 03/43] test(web): include copy Markdown fixture in inventory The navigation-panes recorded-fixture inventory now lists the owner-local copy-markdown.expected.md file. No assertion was weakened; no snapshot or production code changed. --- apps/web/tests/navigation-panes.e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 92999d864f..4c77ccf395 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -587,7 +587,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, [ + 'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md', + ]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', From e671c9e28d11757206bcab4c30193c5553c8d796 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:52:54 +0800 Subject: [PATCH 04/43] fix(session-log-export): address Markdown copy review Preserve click activation by admitting a promised ClipboardItem write synchronously, own its preparation and write promises, and distinguish preparation from clipboard failures. Fold the title from the single readSession snapshot, preserve indentation, close unfinished assistant fences, and restore the shell.overlay props contract. Update the README pair and browser fixture inventory. --- apps/web/tests/navigation-panes.e2e.ts | 15 + docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../session-log-export/README.i18n.yaml | 20 +- .../session-log-export/README.md | 17 +- .../session-log-export/README.zh.md | 17 +- .../session-log-export/package.json | 1 + .../src/client/CopyToast.tsx | 7 +- .../src/client/HeaderAction.tsx | 2 +- .../src/client/copy-controller.ts | 128 +++++-- .../session-log-export/src/client/locales.ts | 6 +- .../session-log-export/src/index.ts | 17 +- .../session-log-export/src/markdown.ts | 118 ++++-- .../tests/client-apply.client.spec.tsx | 2 +- .../tests/copy-controller.client.spec.ts | 348 ++++++++++++++---- .../tests/copy-toast.client.spec.tsx | 22 +- .../tests/header-action.client.spec.tsx | 14 +- .../tests/markdown-route.host.spec.ts | 34 +- .../tests/markdown.host.spec.ts | 107 ++++++ .../session-log-export/tsconfig.host.json | 1 + pnpm-lock.yaml | 3 + 21 files changed, 689 insertions(+), 194 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4c77ccf395..ce979f4774 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -417,6 +417,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) await ensureSeedOpen(page) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function') + expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function') + await page.evaluate(() => { + const clipboard = navigator.clipboard + const originalWrite = clipboard.write.bind(clipboard) + Reflect.set(globalThis, '__dshClipboardWrites', 0) + Object.defineProperty(clipboard, 'write', { + configurable: true, + value: (...args: Parameters) => { + Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1) + return originalWrite(...args) + }, + }) + }) const markdownResponse = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) @@ -427,6 +441,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(response.status()).toBe(200) expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) const markdown = await page.evaluate(() => navigator.clipboard.readText()) const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2ee5fbad73..6ffbfde471 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2859,7 +2859,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eb55be3fc1..ace7ab4961 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2861,7 +2861,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 94e9c5ec76..ff192b51ec 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,14 +18,14 @@ en: f37f81af3601784d zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: 36a79c163dd92469 - zh: b31cb116dc950941 + en: ff52c54e4c6486b8 + zh: 9ea4810f86ac4d6c /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 2b9dbec4877d332e zh: dcf6638938eedb01 /deepseek-ai-dsh-session-log-export/use-this-package/composition: - en: 2fef1fe4411cc5da - zh: cff5dbcc4901402b + en: 4168a2d3e653ab73 + zh: 8bf955cfab7b08e2 /deepseek-ai-dsh-session-log-export/use-this-package/configuration: en: 377a0d8860c46f63 zh: 9eb7698f813b2fa3 @@ -36,8 +36,8 @@ en: 44be1f1d1903d12a zh: c8f1eb5b92b71a30 /deepseek-ai-dsh-session-log-export/use-this-package/failures: - en: 3c274dea47f7f2c6 - zh: e83b2d6ca29f22ec + en: e92a382bebfe99e3 + zh: 671178b06808bfb9 /deepseek-ai-dsh-session-log-export/understand-the-implementation: en: 7cb82a6851405f95 zh: b8c525170d19dc6c @@ -48,8 +48,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: fc740b3b80678249 - zh: 106f93ce651127d4 + en: 20fbc8fb02bf8c5e + zh: c6ad27c484f0f2bf /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 @@ -69,8 +69,8 @@ en: 61e7dbf79010d071 zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 0c0c46a6ec873533 - zh: 7c4183401c37d356 + en: 9aad3e0c329709e4 + zh: 41f16123976897bf /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 01155f799b..3e6d5baf1f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -29,11 +29,11 @@ Use this package when the Web bundle should let users export a session log. It r ### Copy as Markdown -`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. +`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. -The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. The text ends with exactly one newline. +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. -The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header disables its copy row until the operation settles, then restores it. A success or failure toast is shown through the root overlay, so it survives the menu closing or the Session changing. +The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. @@ -48,7 +48,7 @@ Choose it for a Web deployment that needs user-facing session export with a visi name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface. ### Configuration @@ -71,7 +71,7 @@ Attachment collection reads declared content fields of built-in Session events a ### Failures -The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown copy reports a failure toast when the Host route is unavailable or the browser rejects clipboard access; it never reports success for a partial or missing transcript. +The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown preparation failure reports a toast asking the user to retry the Session read; a clipboard write failure reports a separate toast asking the user to check clipboard permission. Neither reports success for a partial or missing transcript, and no pending toast is shown. ----- @@ -97,9 +97,9 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. The controller fetches the document-relative `api/session.markdown?sessionId=` route and writes the response text with the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title through the same query service, and passes the raw events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. The controller starts a deferred `ClipboardItem` write synchronously when the host supports it, then fetches the document-relative `api/session.markdown?sessionId=` route; otherwise it fetches first and uses the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title from the same returned events with `foldSessionTitle`, and passes the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. -The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its success notice always describe the first click's Session. Disposal aborts the fetch and suppresses a late clipboard write or notice. +The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work.
@@ -144,6 +144,9 @@ These limits define when this package is a poor fit or needs special operational - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. - **Markdown contains committed history only** — an unsettled live token chunk has no committed text, so it is absent from the copied transcript. Compaction and fork inheritance follow the raw log rather than the current model context. +- **Markdown copy is the selected Session only** — descendant Session logs are exported by the ZIP download, not by the Markdown copy. +- **Transcript text is buffered in memory** — the complete Markdown string and its Blob are held before the clipboard accepts them. Browser clipboard limits can reject a large export; this package does not add a byte cap or truncate the requested complete transcript. +- **Accepted clipboard writes cannot be rolled back** — disposal stops pending preparation and feedback, but an operating-system write that already succeeded remains on the clipboard. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index e067ac8e43..723ba43674 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" ### 复制为 Markdown -`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。 +`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 -剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 在操作结束前禁用各自的复制行,结束后恢复。成功或失败浮层通过根 overlay 显示,因此菜单关闭或 Session 切换后仍然存在。 +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,写入会在点击时同步开始,并以承诺的 `text/plain` Blob 在读取记录期间继续;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入 Blob;操作系统已经接受的剪贴板写入无法回滚。 挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 @@ -48,7 +48,7 @@ kind: "package-reference" name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。 ### 配置 @@ -71,7 +71,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 失败 -当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Host 路由不可用或浏览器拒绝剪贴板访问时,Markdown 复制显示失败浮层;它不会为不完整或缺失的记录报告成功。 +当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Markdown 准备失败显示重试 Session 读取的浮层;剪贴板写入失败显示单独的检查剪贴板权限浮层。二者都不会为不完整或缺失的记录报告成功,也不会显示进行中的浮层。 ----- @@ -97,9 +97,9 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取文档相对的 `api/session.markdown?sessionId=` 路由,并用共享剪贴板助手写入响应文本。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,通过同一查询服务折叠最新标题,再把原始事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。宿主支持时,控制器同步开始延迟的 `ClipboardItem` 写入,然后读取文档相对的 `api/session.markdown?sessionId=` 路由;否则先读取,再使用共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,用 `foldSessionTitle` 从同一批返回事件折叠最新标题,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 -控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其成功提示始终描述第一次点击的 Session。释放时中止请求,并抑制迟到的剪贴板写入或提示。 +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入承诺的 Blob、抑制提示,并等待已接受的剪贴板工作完成。
@@ -144,6 +144,9 @@ Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 - **Markdown 只包含已提交历史**——未稳定的实时 token 分块没有已提交文本,因此不会出现在复制的记录中。压缩与 fork 继承遵循原始日志,而非当前模型上下文。 +- **Markdown 复制仅限选中的 Session**——子会话日志由 ZIP 下载导出,而不是 Markdown 复制。 +- **记录文本缓冲在内存中**——完整的 Markdown 字符串及其 Blob 在剪贴板接受前都会保留。浏览器剪贴板限制可能拒绝大型导出;本包不增加字节上限,也不截断请求的完整记录。 +- **已接受的剪贴板写入无法回滚**——释放会停止待处理的准备与反馈,但操作系统已经成功的写入会留在剪贴板中。 ### 开发备注 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index d5c457baa6..dd21cd9446 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -68,6 +68,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", "react": "^18.2.0" }, "dsh": { diff --git a/packages/session-query/session-log-export/src/client/CopyToast.tsx b/packages/session-query/session-log-export/src/client/CopyToast.tsx index 3d49e52e04..2a7ac3d4cc 100644 --- a/packages/session-query/session-log-export/src/client/CopyToast.tsx +++ b/packages/session-query/session-log-export/src/client/CopyToast.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SessionMarkdownCopyState } from './copy-controller.ts' import { NS } from './locales.ts' @@ -13,7 +13,8 @@ export interface SessionMarkdownCopyToastInjected { } /** Props of the root overlay copy notice. */ -export type SessionMarkdownCopyToastProps = PropsLocale +export type SessionMarkdownCopyToastProps = PropsRuntime<'shell.overlay'> + & PropsLocale & InjectFace /** @@ -31,7 +32,7 @@ export function SessionMarkdownCopyToast({ : ( } onDone={dismiss} /> diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index 55af4ab1e0..fcf0cfd862 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -80,7 +80,7 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderPr aria-label={t('header.more')} aria-haspopup="menu" aria-expanded={open} - aria-busy={busy} + aria-busy={busy || copying} onClick={() => { setOpen(value => !value) }} > diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts index effb39e190..50eeb0d1cb 100644 --- a/packages/session-query/session-log-export/src/client/copy-controller.ts +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -2,9 +2,14 @@ * Browser controller for the Session Header's "Copy as Markdown" action. One * plugin instance owns one pending copy: the clipboard is a browser-wide * destination, so a second request joins the pending operation instead of - * racing it. The Header disables its copy row from this state; the toast lives - * in a separate `shell.overlay` entry so it survives the menu closing or the - * Session changing. + * racing it. + * + * On hosts with `ClipboardItem` and `navigator.clipboard.write`, the controller + * starts that write synchronously from the click with a promised text Blob, so + * the browser keeps the user activation while the Host prepares the + * transcript. Other hosts fall back to `writeClipboard` after preparation. A + * disposal aborts the read and prevents later text from reaching the Blob, but + * an accepted operating-system clipboard write cannot be rolled back. * @module */ @@ -13,8 +18,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import { SESSION_MARKDOWN_EXPORT_ROUTE } from '../routes.ts' -/** Whether the latest copy attempt succeeded or failed. */ -export type SessionMarkdownCopyNoticeKind = 'success' | 'failed' +/** Whether the latest copy attempt succeeded, prepared no transcript, or could not write. */ +export type SessionMarkdownCopyNoticeKind = 'success' | 'preparationFailed' | 'clipboardFailed' /** One settled copy notice; `seq` keys the toast so a repeat copy restarts it. */ export interface SessionMarkdownCopyNotice { @@ -30,13 +35,42 @@ export interface SessionMarkdownCopyState { notice: SessionMarkdownCopyNotice | null } +/** Browser clipboard operations used by one copy attempt. */ +export interface SessionMarkdownClipboard { + /** Whether a deferred write can start inside the click's activation window. */ + supportsDeferredWrite(): boolean + /** Start one deferred text write; the browser observes the data promise as it settles. */ + writeDeferred(data: Promise): Promise + /** Write prepared text on hosts without the deferred API; false means the browser rejected it. */ + writeText(text: string): Promise +} + /** HTTP carrier for the Markdown route. */ type Fetch = (input: string | URL, init?: RequestInit) => Promise -/** Host clipboard write; false means the browser rejected it. */ -type Write = (text: string) => Promise const INITIAL: SessionMarkdownCopyState = { copying: false, notice: null } +const browserClipboard: SessionMarkdownClipboard = { + supportsDeferredWrite() { + const clipboard: unknown = Reflect.get(navigator, 'clipboard') + return typeof globalThis.ClipboardItem === 'function' + && typeof clipboard === 'object' && clipboard !== null + && typeof Reflect.get(clipboard, 'write') === 'function' + }, + writeDeferred(data) { + return navigator.clipboard.write([new ClipboardItem({ 'text/plain': data })]) + }, + writeText: writeClipboard, +} + +function startWrite(start: () => Promise): Promise { + try { + return start() + } catch (error) { + return Promise.reject(new Error('clipboard write could not start', { cause: error })) + } +} + /** Owns the one pending Markdown copy for this plugin instance. */ export class SessionMarkdownCopyController { /** uSES-safe state source shared by every Header row and the overlay toast. */ @@ -48,14 +82,14 @@ export class SessionMarkdownCopyController { constructor( private readonly fetcher: Fetch = (input, init) => fetch(input, init), - private readonly write: Write = writeClipboard, + private readonly clipboard: SessionMarkdownClipboard = browserClipboard, ) {} /** * Copy one Session's complete transcript. A request received while a copy is * pending joins that operation and keeps its originally captured Session. * @param sessionId - Session captured by the click. - * @returns after the clipboard write settles, a failure notice is published, or a late post-disposal request is ignored. + * @returns after the clipboard work settles, a failure notice is published, or a late post-disposal request is ignored. */ copy(sessionId: SessionId): Promise { if (this.disposed) return Promise.resolve() @@ -75,9 +109,10 @@ export class SessionMarkdownCopyController { } /** - * Abort a pending fetch, suppress a late clipboard write or notice, and - * reach quiescence. - * @returns after the pending operation settles. + * Abort a pending read, suppress a late clipboard write or notice, and reach + * quiescence. A write already accepted by the operating system cannot be + * undone. + * @returns after the pending operation and any admitted clipboard work settle. */ async dispose(): Promise { this.disposed = true @@ -87,23 +122,68 @@ export class SessionMarkdownCopyController { this.store.set(INITIAL) } - private async run(sessionId: SessionId, signal: AbortSignal): Promise { + private run(sessionId: SessionId, signal: AbortSignal): Promise { this.store.update((state) => { state.copying = true }) + const preparation = this.prepare(sessionId, signal) + if (this.clipboard.supportsDeferredWrite()) { + const data = preparation.then((markdown) => { + if (this.isCancelled() || signal.aborted) throw new Error('Session Markdown copy cancelled') + return new Blob([markdown], { type: 'text/plain' }) + }) + const write = startWrite(() => this.clipboard.writeDeferred(data)) + return this.settleDeferred(preparation, data, write) + } + return this.settleFallback(preparation) + } + + private async prepare(sessionId: SessionId, signal: AbortSignal): Promise { + const query = new URLSearchParams({ sessionId }) + const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { signal }) + if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) + return response.text() + } + + private async settleDeferred( + preparation: Promise, + data: Promise, + write: Promise, + ): Promise { try { - const query = new URLSearchParams({ sessionId }) - const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` - const response = await this.fetcher(route, { signal }) - if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) - const markdown = await response.text() - // The plugin may unload while the fetch or a clipboard call is pending; - // never write or report after disposal. - if (this.isCancelled()) return - if (!await this.write(markdown)) throw new Error('the browser rejected the clipboard write') + const [prepared, blob, written] = await Promise.allSettled([preparation, data, write]) if (this.isCancelled()) return + if (prepared.status === 'rejected' || blob.status === 'rejected') { + this.publishNotice('preparationFailed') + return + } + if (written.status === 'rejected') { + this.publishNotice('clipboardFailed') + return + } this.publishNotice('success') - } catch { + } finally { + this.store.update((state) => { state.copying = false }) + } + } + + private async settleFallback(preparation: Promise): Promise { + try { + let markdown: string + try { + markdown = await preparation + } catch { + if (!this.isCancelled()) this.publishNotice('preparationFailed') + return + } if (this.isCancelled()) return - this.publishNotice('failed') + let accepted = false + try { + accepted = await this.clipboard.writeText(markdown) + } catch { + accepted = false + } + if (this.isCancelled()) return + this.publishNotice(accepted ? 'success' : 'clipboardFailed') } finally { this.store.update((state) => { state.copying = false }) } diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 86d37f125a..d52237fa13 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -8,7 +8,8 @@ export const zh = { 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', 'toast.copied': 'Session 已复制为 Markdown', - 'toast.copyFailed': '无法复制 Session,请检查剪贴板权限后重试', + 'toast.preparationFailed': '无法读取 Session 进行复制,请重试', + 'toast.clipboardFailed': '无法写入剪贴板,请检查剪贴板权限后重试', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -25,7 +26,8 @@ export const en: Record = { 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', 'toast.copied': 'Session copied as Markdown.', - 'toast.copyFailed': 'Could not copy the Session. Check clipboard access and try again.', + 'toast.preparationFailed': 'Could not read the Session for copying. Try again.', + 'toast.clipboardFailed': 'Could not write to the clipboard. Check clipboard permission and try again.', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 900aadf469..956db40856 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -6,6 +6,7 @@ import Schema from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type {} from '@deepseek-ai/dsh-session-title' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -118,11 +119,12 @@ function connectionOf(ctx: Context): SessionLogConnection { } /** - * Produce one complete Session transcript as Markdown. The read is a detached - * export observation through session-query's live-preferred corpus, so it - * creates no Agent, retains no Session, and writes no Session data. Abort is - * observed before and after each read; the corpus read itself has no - * cancellation parameter. + * Produce one complete Session transcript as Markdown from one Session + * observation. The read is a detached export observation through + * session-query's live-preferred corpus, so it creates no Agent, retains no + * Session, and writes no Session data. The title is the latest logged title + * value from the same event snapshot as the body. Abort is observed before and + * after the read; the corpus read itself has no cancellation parameter. * @param ctx - host context carrying Connection and session-query. * @param request - authenticated Fetch request with a required `sessionId` query. * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. @@ -141,9 +143,8 @@ async function sessionMarkdownResponse(ctx: Context, request: Request): Promise< request.signal.throwIfAborted() const snapshot = await sessionQuery.readSession(sessionId) request.signal.throwIfAborted() - const title = await sessionQuery.readTitle(sessionId, request.signal) - request.signal.throwIfAborted() - return new Response(formatSessionMarkdown({ title: title?.title, events: snapshot.events }), { + const title = snapshot.events.findLast(event => event.type === 'session/title')?.data.title + return new Response(formatSessionMarkdown({ title, events: snapshot.events }), { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index fa5ba7b433..715ebbd6f0 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -82,7 +82,9 @@ export function formatSessionMarkdown(input: SessionMarkdownInput): string { } if (pending.length > 0) sections.push(collapsibleSection(pending)) const final = turn.segments[finalIndex] - const finalText = final?.kind === 'assistant' ? final.text.trim() : '' + const finalText = final?.kind === 'assistant' + ? closeUnfinishedFence(trimBlankEdgeLines(final.text)) + : '' if (finalText !== '') sections.push(finalText) } return `${sections.join('\n\n').trimEnd()}\n` @@ -121,7 +123,7 @@ function collectTurns(events: readonly SessionEvent[]): Turn[] { const message = event.data.message const text = assistantText(message) const toolCalls = message.content.some(block => block.type === 'tool-call') - if (text.trim() !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) + if (trimBlankEdgeLines(text) !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) break } case 'tool/call': { @@ -170,7 +172,10 @@ function collapsibleSection(entries: readonly TurnEntry[]): string { function renderEntry(entry: TurnEntry): string { if (entry.kind === 'assistant') { - return `**Assistant**\n\n${escapeWrapperTagsOutsideFences(entry.text.trim())}` + const text = escapeWrapperTagsOutsideFences( + closeUnfinishedFence(trimBlankEdgeLines(entry.text)), + ) + return `**Assistant**\n\n${text}` } const heading = `**Tool: \`${escapeWrapperTags(entry.name)}\`**` const argumentsText = entry.arguments === '' @@ -263,38 +268,93 @@ function fenced(text: string, language = ''): string { return `${fence}${language}\n${content}\n${fence}` } -// Arbitrary commentary inside a generated details section must not close the -// wrapper; fenced code blocks keep their exact text because their backtick -// fence already prevents HTML parsing. -function escapeWrapperTagsOutsideFences(markdown: string): string { +interface Fence { + readonly character: string + readonly length: number +} + +interface ScannedLine { + readonly text: string + readonly code: boolean +} + +interface MarkdownScan { + readonly lines: readonly ScannedLine[] + readonly openFence: Fence | undefined +} + +// Remove only blank edge lines, preserving the first and last content line's +// indentation and interior blank lines. +function trimBlankEdgeLines(text: string): string { + const lines = text.replace(/\r\n?/gu, '\n').split('\n') + let start = 0 + let end = lines.length + while (start < end && lines[start]?.trim() === '') start += 1 + while (end > start && lines[end - 1]?.trim() === '') end -= 1 + return lines.slice(start, end).join('\n') +} + +// Scan fenced and indented code once so wrapper escaping and unfinished-fence +// closure agree on what is code. This is a narrow Markdown block heuristic, not +// a parser. +function scanMarkdown(markdown: string): MarkdownScan { const lines = markdown.replace(/\r\n?/gu, '\n').split('\n') - let fenceCharacter: string | undefined - let fenceLength = 0 - const output: string[] = [] + const scanned: ScannedLine[] = [] + let fence: Fence | undefined + let indented = false for (const line of lines) { - if (fenceCharacter === undefined) { - const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) - if (opening !== null) { - const marker = String(opening[1]) - const info = String(opening[2]) - if (marker.startsWith('~') || !info.includes('`')) { - fenceCharacter = marker[0] - fenceLength = marker.length - output.push(line) - continue - } - } - output.push(escapeWrapperTags(line)) + if (fence !== undefined) { + scanned.push({ text: line, code: true }) + if (closesFence(line, fence)) fence = undefined continue } - const closing = new RegExp(`^ {0,3}${fenceCharacter}{${String(fenceLength)},}\\s*$`, 'u') - if (closing.test(line)) { - fenceCharacter = undefined - fenceLength = 0 + const opening = openingFence(line) + if (opening !== undefined) { + fence = opening + indented = false + scanned.push({ text: line, code: true }) + continue } - output.push(line) + if (/^(?: {4}|\t)/u.test(line)) { + indented = true + scanned.push({ text: line, code: true }) + continue + } + scanned.push({ text: line, code: indented && line.trim() === '' }) + if (line.trim() !== '') indented = false } - return output.join('\n') + return { lines: scanned, openFence: fence } +} + +function openingFence(line: string): Fence | undefined { + const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) + if (opening === null) return undefined + const marker = String(opening[1]) + const info = String(opening[2]) + if (marker.startsWith('`') && info.includes('`')) return undefined + return { character: marker.slice(0, 1), length: marker.length } +} + +function closesFence(line: string, fence: Fence): boolean { + const closing = new RegExp(`^ {0,3}${fence.character}{${String(fence.length)},}\\s*$`, 'u') + return closing.test(line) +} + +// Close an assistant message that ended inside a code fence so the next +// message or generated details closer cannot be swallowed by that block. +function closeUnfinishedFence(markdown: string): string { + const scan = scanMarkdown(markdown) + if (scan.openFence === undefined) return markdown + const content = markdown.replace(/\n+$/u, '') + return `${content}\n${scan.openFence.character.repeat(scan.openFence.length)}` +} + +// Arbitrary commentary inside a generated details section must not close the +// wrapper. Fenced and indented code keep their exact text. +function escapeWrapperTagsOutsideFences(markdown: string): string { + return scanMarkdown(markdown).lines + .map(line => line.code ? line.text : escapeWrapperTags(line.text)) + .join('\n') } function escapeWrapperTags(text: string): string { diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 0613c9c9c2..f8ed7792bf 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -85,7 +85,7 @@ describe('session-log-download browser plugin', () => { expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected await injected.copyMarkdown(SID) - expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') overlayInjected.dismiss() expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() diff --git a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts index bbcaa27711..08f04acb25 100644 --- a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts @@ -1,7 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' +import { + SessionMarkdownCopyController, + type SessionMarkdownClipboard, +} from '../src/client/copy-controller.ts' const SID = 'session-copy-controller' as SessionId const OTHER = 'session-copy-other' as SessionId @@ -11,20 +14,41 @@ afterEach(() => { vi.unstubAllGlobals() }) +function fallbackClipboard(writeText: (text: string) => Promise): SessionMarkdownClipboard { + return { + supportsDeferredWrite: () => false, + writeDeferred: () => Promise.reject(new Error('deferred write must not start')), + writeText, + } +} + describe('SessionMarkdownCopyController', () => { - it('fetches the Markdown route and writes the complete response', async () => { - const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('# Title\n\nText\n', { status: 200 })) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) + it('starts the deferred write synchronously and resolves its Blob promise later', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + const writeDeferred = vi.fn((data: Promise) => { + deferred = data + return Promise.resolve() + }) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred, + writeText: vi.fn(async () => false), + }, + ) - await controller.copy(SID) + const pending = controller.copy(SID) + expect(writeDeferred).toHaveBeenCalledOnce() + expect(controller.store.getSnapshot().copying).toBe(true) + responses.resolve(new Response('# Title\n\nText\n')) + await pending - expect(fetcher).toHaveBeenCalledOnce() - const [route, init] = fetcher.mock.calls[0]! - expect(route).toBe(`api/session.markdown?sessionId=${SID}`) - expect(init?.method).toBeUndefined() - expect(init?.signal).toBeInstanceOf(AbortSignal) - expect(write).toHaveBeenCalledWith('# Title\n\nText\n') + if (deferred === undefined) throw new Error('clipboard data promise was not provided') + const blob = await deferred + expect(blob).toBeInstanceOf(Blob) + expect(await blob.text()).toBe('# Title\n\nText\n') expect(controller.store.getSnapshot()).toMatchObject({ copying: false, notice: { kind: 'success' }, @@ -34,20 +58,20 @@ describe('SessionMarkdownCopyController', () => { it('joins a second-Session request to the pending copy and keeps its captured target', async () => { const responses = Promise.withResolvers() const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => responses.promise) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) + const controller = new SessionMarkdownCopyController(fetcher, { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }) const first = controller.copy(SID) const second = controller.copy(OTHER) expect(first).toBe(second) - expect(controller.store.getSnapshot().copying).toBe(true) expect(fetcher).toHaveBeenCalledOnce() expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.markdown?sessionId=${SID}`) responses.resolve(new Response('markdown for first')) await first - expect(write).toHaveBeenCalledOnce() - expect(write).toHaveBeenCalledWith('markdown for first') expect(controller.store.getSnapshot().copying).toBe(false) await controller.copy(OTHER) @@ -55,83 +79,120 @@ describe('SessionMarkdownCopyController', () => { expect(fetcher.mock.calls[1]?.[0]).toBe(`api/session.markdown?sessionId=${OTHER}`) }) - it('publishes failures for HTTP, transport, and clipboard rejection without leaking rejections', async () => { + it('falls back to writing prepared text only when deferred APIs are missing', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) + + const pending = controller.copy(SID) + expect(writeText).not.toHaveBeenCalled() + responses.resolve(new Response('# Fallback')) + await pending + + expect(writeText).toHaveBeenCalledWith('# Fallback') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('classifies HTTP, transport, and deferred preparation failures', async () => { const http = new SessionMarkdownCopyController( async () => new Response('hidden detail', { status: 500 }), - vi.fn(async () => true), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }, ) await http.copy(SID) - expect(http.store.getSnapshot()).toMatchObject({ - copying: false, - notice: { kind: 'failed' }, - }) + expect(http.store.getSnapshot().notice?.kind).toBe('preparationFailed') - const write = vi.fn() - const transport = new SessionMarkdownCopyController(async () => { throw new Error('offline') }, write) + const transport = new SessionMarkdownCopyController( + async () => { throw new Error('offline') }, + fallbackClipboard(vi.fn(async () => true)), + ) await transport.copy(SID) - expect(transport.store.getSnapshot().notice?.kind).toBe('failed') - expect(write).not.toHaveBeenCalled() + expect(transport.store.getSnapshot().notice?.kind).toBe('preparationFailed') + }) + + it('classifies native denial and fallback rejection as clipboard failures', async () => { + const denied = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => { throw new Error('denied') }, + writeText: vi.fn(async () => false), + }, + ) + await denied.copy(SID) + expect(denied.store.getSnapshot().notice?.kind).toBe('clipboardFailed') const rejected = new SessionMarkdownCopyController( async () => new Response('markdown'), - async () => false, + fallbackClipboard(async () => false), ) await rejected.copy(SID) - expect(rejected.store.getSnapshot().notice?.kind).toBe('failed') + expect(rejected.store.getSnapshot().notice?.kind).toBe('clipboardFailed') }) - it('accepts a new copy after a failed operation settles', async () => { - const fetcher = vi.fn() - .mockResolvedValueOnce(new Response('hidden', { status: 500 })) - .mockResolvedValueOnce(new Response('markdown')) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) - - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('failed') - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('success') - expect(fetcher).toHaveBeenCalledTimes(2) - expect(write).toHaveBeenCalledOnce() - }) - - it('dismisses a notice and ignores a dismissal with no notice', async () => { + it('contains an immediate native denial followed by a later preparation rejection', async () => { + const responses = Promise.withResolvers() const controller = new SessionMarkdownCopyController( - async () => new Response('markdown'), - async () => true, + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred: async () => { throw new Error('denied immediately') }, + writeText: vi.fn(async () => false), + }, ) - controller.dismiss() - expect(controller.store.getSnapshot().notice).toBeNull() - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('success') - controller.dismiss() - expect(controller.store.getSnapshot().notice).toBeNull() + const pending = controller.copy(SID) + responses.reject(new Error('read failed later')) + await pending + + expect(controller.store.getSnapshot().notice?.kind).toBe('preparationFailed') + expect(controller.store.getSnapshot().copying).toBe(false) }) - it('aborts pending fetches and suppresses a write that settles after disposal', async () => { - const gate = Promise.withResolvers() - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(() => gate.promise, write) + it('aborts the read and prevents text after disposal before readiness', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred: (data) => { + deferred = data + return Promise.resolve() + }, + writeText: vi.fn(async () => false), + }, + ) const pending = controller.copy(SID) const disposing = controller.dispose() - gate.resolve(new Response('late markdown')) + responses.resolve(new Response('late markdown')) await disposing await pending - expect(write).not.toHaveBeenCalled() + if (deferred === undefined) throw new Error('clipboard data promise was not provided') + await expect(deferred).rejects.toThrow('cancelled') expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) - await expect(controller.copy(SID)).resolves.toBeUndefined() }) - it('suppresses success when disposal starts while the clipboard write is pending', async () => { + it('awaits admitted clipboard work and suppresses its notice on disposal', async () => { const writeGate = Promise.withResolvers() - const write = vi.fn(() => writeGate.promise) - const controller = new SessionMarkdownCopyController(async () => new Response('markdown'), write) - const pending = controller.copy(SID) - await vi.waitFor(() => { expect(write).toHaveBeenCalledOnce() }) + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: () => writeGate.promise.then(() => {}), + writeText: vi.fn(async () => false), + }, + ) + const pending = controller.copy(SID) const disposing = controller.dispose() writeGate.resolve(true) await disposing @@ -140,27 +201,160 @@ describe('SessionMarkdownCopyController', () => { expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) }) - it('suppresses a rejection caused by disposal instead of publishing failure', async () => { - const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - })) - const controller = new SessionMarkdownCopyController(fetcher, vi.fn(async () => true)) - const pending = controller.copy(SID) + it('falls back to writing on a host without deferred clipboard APIs', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) - await controller.dispose() + const pending = controller.copy(SID) + expect(writeText).not.toHaveBeenCalled() + responses.resolve(new Response('fallback markdown')) + await pending + + expect(writeText).toHaveBeenCalledWith('fallback markdown') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + + it('contains a synchronous deferred-write failure', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: () => { throw new Error('constructor failed') }, + writeText: vi.fn(async () => false), + }, + ) + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('clipboardFailed') + }) + + it('does not write fallback text after cancellation before readiness', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + responses.resolve(new Response('late fallback')) + await disposing + await pending + + expect(writeText).not.toHaveBeenCalled() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('classifies a throwing fallback writer and awaits it during disposal', async () => { + const throwing = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(async () => { throw new Error('write failed') }), + ) + await throwing.copy(SID) + expect(throwing.store.getSnapshot().notice?.kind).toBe('clipboardFailed') + + const writeGate = Promise.withResolvers() + const writeText = vi.fn(() => writeGate.promise) + const pendingWrite = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(writeText), + ) + const pending = pendingWrite.copy(SID) + await vi.waitFor(() => { expect(writeText).toHaveBeenCalledOnce() }) + const disposing = pendingWrite.dispose() + writeGate.resolve(true) + await disposing + await pending + expect(pendingWrite.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + + it('suppresses a fallback preparation failure after disposal', async () => { + const responses = Promise.withResolvers() + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(vi.fn(async () => true)), + ) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + responses.reject(new Error('late read failure')) + await disposing await pending expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) }) - it('uses the document-relative default carrier and the host clipboard writer', async () => { + it('ignores copy after disposal', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(vi.fn(async () => true)), + ) + await controller.dispose() + await expect(controller.copy(SID)).resolves.toBeUndefined() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('dismisses a notice and ignores a dismissal with no notice', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }, + ) + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + }) + + it('starts the browser ClipboardItem write synchronously through the default adapter', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + class TestClipboardItem { + readonly text: Promise + constructor(items: Record>) { + const text = items['text/plain'] + if (text === undefined) throw new Error('text/plain data is required') + this.text = text + deferred = text + } + } + const write = vi.fn(async () => {}) + vi.stubGlobal('ClipboardItem', TestClipboardItem) + vi.stubGlobal('navigator', { clipboard: { write } }) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + ) + + const pending = controller.copy(SID) + expect(write).toHaveBeenCalledOnce() + expect(controller.store.getSnapshot().copying).toBe(true) + responses.resolve(new Response('# ClipboardItem')) + await pending + + if (deferred === undefined) throw new Error('ClipboardItem did not receive text data') + const blob = await deferred + expect(await blob.text()).toBe('# ClipboardItem') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('uses the document-relative default carrier and the fallback clipboard helper', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('default markdown')) const writeText = vi.fn(async () => {}) vi.stubGlobal('fetch', fetcher) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }) + vi.stubGlobal('navigator', { clipboard: { writeText } }) const controller = new SessionMarkdownCopyController() await controller.copy(SID) diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx index 52a34b1a97..e491848066 100644 --- a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -3,11 +3,19 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionMarkdownCopyState } from '../src/client/copy-controller.ts' import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' import type { SessionMarkdownCopyToastProps } from '../src/client/CopyToast.tsx' import { en } from '../src/client/locales.ts' +const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } +const standard: GlobalStandardProps = { + useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, + useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, + usePanelInfo: unused, useResource: unused, useWorkspaces: unused, +} + function bindSnapshot(store: ObservableSnapshot) { return function useSessionMarkdownCopy(selector: (state: SessionMarkdownCopyState) => T): T { return useSyncExternalStore( @@ -22,6 +30,7 @@ function bench(notice: SessionMarkdownCopyState['notice']) { const dismiss = vi.fn() const dictionary: Record = en const props: SessionMarkdownCopyToastProps = { + ...standard, useSessionMarkdownCopy: bindSnapshot(store), dismiss, t: key => dictionary[key] ?? key, @@ -43,10 +52,15 @@ describe('Session Markdown copy toast', () => { expect(b.dismiss).toHaveBeenCalledOnce() }) - it('shows the failure outcome with its actionable copy', () => { - const b = bench({ kind: 'failed', seq: 2 }) - expect(b.getByRole('alert').textContent).toBe( - 'Could not copy the Session. Check clipboard access and try again.', + it('shows separate preparation and clipboard failure copy', () => { + const preparation = bench({ kind: 'preparationFailed', seq: 2 }) + expect(preparation.getByRole('alert').textContent).toBe( + 'Could not read the Session for copying. Try again.', + ) + cleanup() + const clipboard = bench({ kind: 'clipboardFailed', seq: 3 }) + expect(clipboard.getByRole('alert').textContent).toBe( + 'Could not write to the clipboard. Check clipboard permission and try again.', ) }) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 91400e6680..86a35d5e04 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -6,12 +6,17 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' -import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' +import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId +const clipboard: SessionMarkdownClipboard = { + supportsDeferredWrite: () => false, + writeDeferred: async () => {}, + writeText: async () => true, +} const dictionary: Record = en const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } @@ -36,7 +41,7 @@ function bindSnapshot(store: ObservableSnapshot) { function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( async () => new Response('markdown'), - async () => true, + clipboard, )) { const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) @@ -115,7 +120,7 @@ describe('Session export Header action', () => { it('disables only the copy row while a copy is pending', async () => { const gate = Promise.withResolvers() - const copyController = new SessionMarkdownCopyController(() => gate.promise, async () => true) + const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard) const b = bench(true, copyController) const pending = copyController.copy(SID) expect(copyController.store.getSnapshot().copying).toBe(true) @@ -127,12 +132,13 @@ describe('Session export Header action', () => { await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - expect(button.getAttribute('aria-busy')).toBe('false') + expect(button.getAttribute('aria-busy')).toBe('true') gate.resolve(new Response('markdown')) await pending expect(b.copyController.store.getSnapshot().copying).toBe(false) await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) + expect(button.getAttribute('aria-busy')).toBe('false') }) it('opens the more-actions menu and downloads through the shared controller', async () => { diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index 3abfa2e855..ef3decdfd4 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -4,8 +4,9 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import type { MessageId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' const SESSION_ID = 'session-markdown-route' as SessionId @@ -34,9 +35,15 @@ const EVENTS: SessionEvent[] = [ }, }, { - type: 'assistant/message', + type: 'session/title', seq: brandNumber(3), time: 3, + data: { title: 'Route title from events', messageSeqs: [brandNumber(2)], source: { kind: 'fallback' } }, + }, + { + type: 'assistant/message', + seq: brandNumber(4), + time: 4, surfaceOp: 'append', data: { turn: 1, @@ -50,7 +57,7 @@ const EVENTS: SessionEvent[] = [ stream: [], }, }, - { type: 'turn/end', seq: brandNumber(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/end', seq: brandNumber(5), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, ] function missingSessionError(): Error { @@ -59,15 +66,10 @@ function missingSessionError(): Error { function query(overrides: { readonly readSession?: (id: SessionId) => Promise - readonly readTitle?: (id: SessionId, signal?: AbortSignal) => Promise } = {}): unknown { return { readSession: overrides.readSession ?? (async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })), - readTitle: overrides.readTitle - ?? (async () => ({ - title: 'Route title', messageSeqs: [], source: { kind: 'fallback' }, eventSeq: 2, updatedAt: 2, - })), } } @@ -103,7 +105,7 @@ describe('Session Markdown Fetch route', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') const markdown = await response.text() - expect(markdown).toContain('# Route title') + expect(markdown).toContain('# Route title from events') expect(markdown).toContain('> Route question') expect(markdown).toContain('Route answer') expect(markdown.endsWith('\n')).toBe(true) @@ -161,16 +163,18 @@ describe('Session Markdown Fetch route', () => { await broken.dispose() }) - it('returns a sanitized 500 when the title read fails after the log read', async () => { - const { shared, connection, dispose } = await mounted(query({ - readTitle: async () => { throw new Error('title path /private/host') }, - })) + it('folds the title and body from one returned snapshot without a second title read', async () => { + const readSession = vi.fn(async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })) + const readTitle = vi.fn(async () => { throw new Error('must not read a second observation') }) + const { shared, connection, dispose } = await mounted({ readSession, readTitle }) const response = await shared.fetch( request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), connection.browserPeer, ) - expect(response.status).toBe(500) - expect(await response.text()).toBe('session markdown export failed to read the stored log') + expect(response.status).toBe(200) + expect(await response.text()).toContain('# Route title from events') + expect(readSession).toHaveBeenCalledOnce() + expect(readTitle).not.toHaveBeenCalled() await dispose() }) }) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts index 1b42768ca4..80fa21c6a6 100644 --- a/packages/session-query/session-log-export/tests/markdown.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -327,6 +327,113 @@ describe('formatSessionMarkdown', () => { expect(markdown).toBe('# Whitespace\n\n> Question\n\nAnswer\n') }) + + it('preserves four-space and tab indentation in final and commentary prose', () => { + const markdown = formatSessionMarkdown({ + title: 'Indented code', + events: [ + start(1, 1), + user(2, 'Show code'), + assistant(3, [text('
\n literal code')]), + assistant(4, [text('\tif (ready) {\n\t run()\n\t}')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('**Assistant**\n\n
\n literal code') + expect(markdown).toContain('\tif (ready) {\n\t run()\n\t}') + expect(markdown).not.toContain('<details>') + expect(markdown.endsWith('\t}\n')).toBe(true) + }) + + it('closes unfinished commentary fences before the generated details closer', () => { + const backtick = formatSessionMarkdown({ + title: 'Backtick fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('Before\n\n```ts\nconst x = 1')]), + assistant(4, [text('After backtick')]), + end(5, 1), + ], + }) + expect(backtick).toContain('const x = 1\n```\n\n
\n\nAfter backtick') + + const tilde = formatSessionMarkdown({ + title: 'Tilde fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('~~~js\nconst y = 2')]), + assistant(4, [text('After tilde')]), + end(5, 1), + ], + }) + expect(tilde).toContain('const y = 2\n~~~\n\n
\n\nAfter tilde') + + const longer = formatSessionMarkdown({ + title: 'Longer fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('````md\nlonger fence')]), + assistant(4, [text('After longer')]), + end(5, 1), + ], + }) + expect(longer).toContain('longer fence\n````\n\n
\n\nAfter longer') + }) + + it('does not treat an invalid backtick info string as an open fence', () => { + const markdown = formatSessionMarkdown({ + title: 'Invalid fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('```bad`info\nstill prose')]), + assistant(4, [text('After invalid')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('```bad`info\nstill prose') + expect(markdown.match(/```/gu)).toHaveLength(1) + expect(markdown).toContain('still prose\n\n
\n\nAfter invalid') + }) + + it('closes an unfinished final fence before the next turn', () => { + const markdown = formatSessionMarkdown({ + title: 'Final fence', + events: [ + start(1, 1), + user(2, 'First question'), + assistant(3, [text('```sh\necho hi')]), + end(4, 1), + start(5, 2), + user(6, 'Second question'), + end(7, 2), + ], + }) + + expect(markdown).toContain('echo hi\n```\n\n> Second question') + expect(markdown.indexOf('echo hi')).toBeLessThan(markdown.indexOf('> Second question')) + }) + + + it('strips blank edge lines without removing indented code blank lines', () => { + const markdown = formatSessionMarkdown({ + title: 'Edge blanks', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('\n first\n\n second\n\n')]), + end(4, 1), + ], + }) + + expect(markdown).toBe('# Edge blanks\n\n> Question\n\n first\n\n second\n') + }) + it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { const markdown = formatSessionMarkdown({ title: 'Escaping', diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 22dad514a4..cfcb54a364 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -20,6 +20,7 @@ { "path": "../../interaction/commands" }, { "path": "../../session/session-format" }, { "path": "../../session/session-persistence" }, + { "path": "../../session/session-title" }, { "path": "../session-query" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c24f6d915f..81f40f1c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12288,6 +12288,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:* version: link:../session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:* + version: link:../../session/session-title react: specifier: ^18.2.0 version: 18.3.1 From e0b32d1d90904f15ecadd4156770278471443e76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:56:42 +0800 Subject: [PATCH 05/43] docs(session-log-export): describe Markdown copy routes Update the client and Host module descriptions and apply JSDoc to mention the authenticated Markdown copy route alongside ZIP download and feedback. Comment-only change. --- packages/session-query/session-log-export/src/client/index.ts | 2 +- packages/session-query/session-log-export/src/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index cf97b268c2..1205617322 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -1,4 +1,4 @@ -/** Browser plugin owning Session export download state and its shared modal. */ +/** Browser plugin owning ZIP download and Markdown copy state, plus feedback controls. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 956db40856..8dec1692c2 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -1,4 +1,4 @@ -/** Session-log download command and Host-owned streaming route. */ +/** Session export command and Host-owned ZIP download and Markdown copy routes. */ import type { Context } from '@deepseek-ai/cordis' import type { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' @@ -73,7 +73,7 @@ const REQUESTED: CommandResult = { } /** - * Register the Web-only `/export` command and authenticated ZIP download route. + * Register the `/export` command and authenticated ZIP download and Markdown copy routes. * @param ctx - Host context carrying the human-command registry. * @param config - resolved compression policy. */ From 8888db9545910fac889d8c7c11e42e3bf3feb714 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:05:37 +0800 Subject: [PATCH 06/43] docs(session-log-export): correct Markdown copy facts Describe the direct session/title field read and same-click deferred clipboard request, cover Markdown copy in the selection guidance, and tighten the Chinese Blob and compaction wording. Re-record the README pair. --- .../session-query/session-log-export/README.i18n.yaml | 10 +++++----- packages/session-query/session-log-export/README.md | 4 ++-- packages/session-query/session-log-export/README.zh.md | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ff192b51ec..0a8e02bfea 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -19,10 +19,10 @@ zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: en: ff52c54e4c6486b8 - zh: 9ea4810f86ac4d6c + zh: 88c52f4ed7b07308 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: - en: 2b9dbec4877d332e - zh: dcf6638938eedb01 + en: a9e2aa65a0a903c5 + zh: 0f928d0130a4c452 /deepseek-ai-dsh-session-log-export/use-this-package/composition: en: 4168a2d3e653ab73 zh: 8bf955cfab7b08e2 @@ -48,8 +48,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: 20fbc8fb02bf8c5e - zh: c6ad27c484f0f2bf + en: 0f71c07df6fe181e + zh: d1cadf6ac1b8a21f /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 3e6d5baf1f..830a5ca74f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -39,7 +39,7 @@ When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, whi ### When to choose it -Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. +Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading. Avoid it when a programmatic or Host-side export is needed: both operations keep the result in the browser, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. ### Composition @@ -97,7 +97,7 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. The controller starts a deferred `ClipboardItem` write synchronously when the host supports it, then fetches the document-relative `api/session.markdown?sessionId=` route; otherwise it fetches first and uses the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title from the same returned events with `foldSessionTitle`, and passes the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and takes the latest `session/title` event's title field from that same snapshot before passing the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 723ba43674..484d3b8328 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -31,15 +31,15 @@ kind: "package-reference" `复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 -剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,写入会在点击时同步开始,并以承诺的 `text/plain` Blob 在读取记录期间继续;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入 Blob;操作系统已经接受的剪贴板写入无法回滚。 +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 ### 何时选择 -为需要带可见下载弹窗的面向用户的会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 +为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:两个操作都把结果留在浏览器中,而非写入 Host 路径。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 ### 组合 @@ -97,9 +97,9 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。宿主支持时,控制器同步开始延迟的 `ClipboardItem` 写入,然后读取文档相对的 `api/session.markdown?sessionId=` 路由;否则先读取,再使用共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,用 `foldSessionTitle` 从同一批返回事件折叠最新标题,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并从同一快照取出最新 `session/title` 事件的 title 字段,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 -控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入承诺的 Blob、抑制提示,并等待已接受的剪贴板工作完成。 +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入由 Promise 提供的 Blob、抑制提示,并等待已接受的剪贴板工作完成。
From e16c52f610f2e3ddb2e18280d983fce3923fbd40 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:28:26 +0800 Subject: [PATCH 07/43] fix(session-log-export): keep Markdown route path internal Remove the unused SESSION_MARKDOWN_EXPORT_PATH package re-export while keeping the internal routes definition and Host import. Read the constant directly from routes in the same-package route test. SESSION_LOG_EXPORT_PATH remains public. --- packages/session-query/session-log-export/src/index.ts | 2 +- .../session-log-export/tests/markdown-route.host.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 8dec1692c2..57c2a850b7 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -42,7 +42,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -export { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' +export { SESSION_LOG_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index ef3decdfd4..6483dcff52 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -7,7 +7,8 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' import { describe, expect, it, vi } from 'vitest' -import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' +import { apply, inject } from '../src/index.ts' +import { SESSION_MARKDOWN_EXPORT_PATH } from '../src/routes.ts' const SESSION_ID = 'session-markdown-route' as SessionId From 687aa118f1ae54df7676b70576e432f2fe931d29 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:46:27 +0800 Subject: [PATCH 08/43] feat(session-query): export backend Session Markdown converter Expose the pure Host formatSessionMarkdown(events, sourcePath?) converter at the package entry. Persistence stat/list now report the selected materialized JSONL artifact path, and the Markdown copy route includes that exact optional path after the title. Frontend progressive display and Session format remain unchanged. --- .../copy-markdown.expected.md | 6 + apps/web/tests/navigation-panes.e2e.ts | 14 ++- docs/subsystems/persistence.i18n.yaml | 6 +- docs/subsystems/persistence.md | 18 ++- docs/subsystems/persistence.zh.md | 18 ++- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- .../session-log-export/README.i18n.yaml | 19 +-- .../session-log-export/README.md | 20 ++- .../session-log-export/README.zh.md | 20 ++- .../session-log-export/src/index.ts | 13 +- .../session-log-export/src/markdown.ts | 49 ++++---- .../tests/markdown-route.host.spec.ts | 65 +++++++++- .../tests/markdown.host.spec.ts | 119 ++++++++++++++---- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 2 + .../tests/jsonl.spec.ts | 36 +++++- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session/session-persistence/src/errors.ts | 8 +- .../session/session-persistence/src/index.ts | 7 ++ 23 files changed, 344 insertions(+), 98 deletions(-) diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md index 1be7dec876..7de5e961c1 100644 --- a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -1,5 +1,11 @@ # NavScenario: first run bash to +Session file: + +```text +{{sessionFile}} +``` + > NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.
3 previous messages diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index ce979f4774..254e1ac9c5 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -6,7 +6,7 @@ // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the // trajectory ledger/timing lanes), turn 2 a markdown-rich reply. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' @@ -14,7 +14,7 @@ import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -443,10 +443,18 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) + const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path + if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location') + expect((await stat(sourcePath)).isFile()).toBe(true) + expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true) const markdown = await page.evaluate(() => navigator.clipboard.readText()) - const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const normalized = markdown + .split(sourcePath).join('{{sessionFile}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```') expect(normalized).toContain('# NavScenario: first run bash to') expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') expect(normalized).toContain('
3 previous messages') diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index aaf59a0f16..054615f66d 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -14,9 +14,9 @@ /session-persistence/crash-recovery-preserves-an-interrupted-turn: en: 534044a0ce482d52 zh: d2e8a7dfeb066946 -/session-persistence/sessionlocation-refusal-diagnostics-artifact-target: - en: 886589f052a916d7 - zh: 2ca126aa700c933c +/session-persistence/sessionlocation-per-session-artifact-metadata: + en: e310ff426d848262 + zh: 8a7667349b0549a7 /session-persistence/sessionheader-metadata-beside-the-log: en: 5795ae03d2d163cd zh: a46ceeb22cfbb6a1 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 3c0c2586a7..8f35e17f05 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision. -## `SessionLocation` — refusal-diagnostics artifact target +## `SessionLocation` — per-Session artifact metadata -`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. +`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index dba0685409..c88b884fdb 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable { 只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。 -## `SessionLocation`——拒绝诊断的产物目标 +## `SessionLocation`——每一 Session 的产物元数据 -`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 +`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cca1649f96..286fc59954 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -7272,6 +7272,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionListValue', declaration: 'export interface SessionListValue {\n readonly items: readonly SessionSummary[];\n}', }, + { + name: 'SessionLocation', + declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', + }, { name: 'SessionLogOffset', declaration: 'export type SessionLogOffset = BrandedNumber<\'SessionLogOffset\'>;', @@ -7350,7 +7354,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionPersistenceSnapshot', - declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n}', + declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n readonly location?: SessionLocation;\n}', }, { name: 'SessionPersistenceStatOptions', diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 0a8e02bfea..5fe8787069 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,11 +18,14 @@ en: f37f81af3601784d zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: ff52c54e4c6486b8 - zh: 88c52f4ed7b07308 + en: 59ba15abb59b6bd0 + zh: 1ea2b05fc05b9e61 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: - en: a9e2aa65a0a903c5 - zh: 0f928d0130a4c452 + en: 4011f686afd24f09 + zh: 2710b9995c80ba1f +/deepseek-ai-dsh-session-log-export/use-this-package/backend-conversion: + en: 252bc78a7850557a + zh: e695658a94c8c2bf /deepseek-ai-dsh-session-log-export/use-this-package/composition: en: 4168a2d3e653ab73 zh: 8bf955cfab7b08e2 @@ -48,8 +51,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: 0f71c07df6fe181e - zh: d1cadf6ac1b8a21f + en: d4e02b56ffb8ee8a + zh: 0a329039788a13ee /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 @@ -69,8 +72,8 @@ en: 61e7dbf79010d071 zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 9aad3e0c329709e4 - zh: 41f16123976897bf + en: 961c206403204080 + zh: c0cea2c965cc0b1b /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 830a5ca74f..31416a2731 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -31,7 +31,7 @@ Use this package when the Web bundle should let users export a session log. It r `Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. -The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. @@ -39,7 +39,20 @@ When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, whi ### When to choose it -Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading. Avoid it when a programmatic or Host-side export is needed: both operations keep the result in the browser, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. +Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading, or for a backend caller that already holds a complete committed logical event snapshot and needs the same Markdown conversion. Avoid it when a Host path write is needed: the browser operations keep their result in the browser, and the conversion utility performs no IO. The logs are serialized from persistence read handles, so any mounted backend is supported. + +### Backend conversion + +The package entry also exports `formatSessionMarkdown(events, sourcePath?)`: a pure backend utility for callers that already hold a complete committed logical event snapshot in seq order, including any inherited prefix. It reads the latest `session/title` event, returns Markdown ending in one newline, performs no IO, starts no plugin or Session, and does not mutate the events. An optional backend-resolved `sourcePath` is emitted verbatim in a dynamic fenced `Session file:` block after the title; omitted or empty paths add no block. Missing or blank titles produce `Untitled Session`. Only the supplied events are converted; descendant Session logs are not read. + +```ts +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { formatSessionMarkdown } from '@deepseek-ai/dsh-session-log-export' + +export function convertTranscript(events: readonly SessionEvent[], sourcePath?: string): string { + return formatSessionMarkdown(events, sourcePath) +} +``` ### Composition @@ -97,7 +110,7 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and takes the latest `session/title` event's title field from that same snapshot before passing the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and passes those events to the pure formatter. It separately asks `ctx.get('sessionPersistence')?.stat` for the selected materialized artifact path and passes that optional path to the formatter; a metadata-read failure is a sanitized preparation failure. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work. @@ -147,6 +160,7 @@ These limits define when this package is a poor fit or needs special operational - **Markdown copy is the selected Session only** — descendant Session logs are exported by the ZIP download, not by the Markdown copy. - **Transcript text is buffered in memory** — the complete Markdown string and its Blob are held before the clipboard accepts them. Browser clipboard limits can reject a large export; this package does not add a byte cap or truncate the requested complete transcript. - **Accepted clipboard writes cannot be rolled back** — disposal stops pending preparation and feedback, but an operating-system write that already succeeded remains on the clipboard. +- **The source path is observed metadata, not an atomic log claim** — `stat` identifies the selected materialized artifact at export time. Live committed events can be ahead of that file, and an unmaterialized Session has no source-path block. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 484d3b8328..67b94237e2 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" `复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 @@ -39,7 +39,20 @@ kind: "package-reference" ### 何时选择 -为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:两个操作都把结果留在浏览器中,而非写入 Host 路径。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 +为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它,也适用于已经持有完整已提交逻辑事件快照并需要相同 Markdown 转换的后端调用方。仅需 Host 路径写入时避免使用:浏览器操作把结果留在浏览器中,转换工具不执行 IO。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 + +### 后端转换 + +包入口还导出 `formatSessionMarkdown(events, sourcePath?)`:面向已经持有完整已提交逻辑事件快照(按 seq 排序,包含任何继承前缀)的调用方的纯后端工具。它读取最新的 `session/title` 事件,返回以恰好一个换行结尾的 Markdown,不执行 IO、不启动插件或 Session,也不修改事件。可选的、由后端解析的 `sourcePath` 会原样写入标题后的动态围栏 `Session file:` 文本块;省略或空路径不添加该块。缺失或空白标题生成 `Untitled Session`。它只转换传入的事件;不读取子会话日志。 + +```ts +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { formatSessionMarkdown } from '@deepseek-ai/dsh-session-log-export' + +export function convertTranscript(events: readonly SessionEvent[], sourcePath?: string): string { + return formatSessionMarkdown(events, sourcePath) +} +``` ### 组合 @@ -97,7 +110,7 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并从同一快照取出最新 `session/title` 事件的 title 字段,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并把事件交给纯格式化器。它另外通过 `ctx.get('sessionPersistence')?.stat` 获取选中已物化产物的路径,并把可选路径传给格式化器;元数据读取失败属于经过清理的准备失败。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入由 Promise 提供的 Blob、抑制提示,并等待已接受的剪贴板工作完成。 @@ -147,6 +160,7 @@ Header 向 Markdown 复制控制器请求被点击的 Session。点击期间, - **Markdown 复制仅限选中的 Session**——子会话日志由 ZIP 下载导出,而不是 Markdown 复制。 - **记录文本缓冲在内存中**——完整的 Markdown 字符串及其 Blob 在剪贴板接受前都会保留。浏览器剪贴板限制可能拒绝大型导出;本包不增加字节上限,也不截断请求的完整记录。 - **已接受的剪贴板写入无法回滚**——释放会停止待处理的准备与反馈,但操作系统已经成功的写入会留在剪贴板中。 +- **来源路径是观察到的元数据,不是原子日志声明**——`stat` 标识导出时选中的已物化产物。实时已提交事件可能领先于该文件,未物化的 Session 没有来源路径块。 ### 开发备注 diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 57c2a850b7..5fe99dce5f 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -6,7 +6,6 @@ import Schema from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' -import type {} from '@deepseek-ai/dsh-session-title' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -38,6 +37,7 @@ export type { SessionLogExportReady, SessionLogZipEntry, } from './archive.ts' +export { formatSessionMarkdown } from './markdown.ts' export const name = 'session-log-download' export const inject = ['commands', 'connection'] @@ -122,9 +122,9 @@ function connectionOf(ctx: Context): SessionLogConnection { * Produce one complete Session transcript as Markdown from one Session * observation. The read is a detached export observation through * session-query's live-preferred corpus, so it creates no Agent, retains no - * Session, and writes no Session data. The title is the latest logged title - * value from the same event snapshot as the body. Abort is observed before and - * after the read; the corpus read itself has no cancellation parameter. + * Session, and writes no Session data. The formatter reads the latest title from + * the same event snapshot as the body. Abort is observed before and after the + * read; the corpus read itself has no cancellation parameter. * @param ctx - host context carrying Connection and session-query. * @param request - authenticated Fetch request with a required `sessionId` query. * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. @@ -143,8 +143,9 @@ async function sessionMarkdownResponse(ctx: Context, request: Request): Promise< request.signal.throwIfAborted() const snapshot = await sessionQuery.readSession(sessionId) request.signal.throwIfAborted() - const title = snapshot.events.findLast(event => event.type === 'session/title')?.data.title - return new Response(formatSessionMarkdown({ title, events: snapshot.events }), { + const metadata = await ctx.get('sessionPersistence')?.stat(sessionId, { signal: request.signal }) + request.signal.throwIfAborted() + return new Response(formatSessionMarkdown(snapshot.events, metadata?.location?.path), { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index 715ebbd6f0..84261bee39 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -1,9 +1,11 @@ /** - * Renders one complete committed Session log as Markdown for the "Copy as - * Markdown" action. The raw log is authoritative: compaction surface - * replacements never remove earlier messages, and fork-inherited history - * appears once. Reasoning, system and developer messages, injected non-human - * context, request headers, and runtime records are omitted. + * Pure backend converter for one complete committed Session log. The "Copy as + * Markdown" Host route uses it, and the package entry exports it for callers + * that already hold the logical events. The raw log is authoritative: + * compaction surface replacements never remove earlier messages, and + * fork-inherited history appears once. Reasoning, system and developer + * messages, injected non-human context, request headers, and runtime records + * are omitted. * @module */ @@ -13,18 +15,11 @@ import type { ToolResultMessage, UserMessage, } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' /** Heading used when the log carries no title and the Session has no name. */ -export const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' - -/** Input to {@link formatSessionMarkdown}. */ -export interface SessionMarkdownInput { - /** Latest log-backed title, or `undefined` when the log has no title event. */ - readonly title: string | undefined - /** Complete committed raw event log in seq order, including inherited history. */ - readonly events: readonly SessionEvent[] -} +const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' interface AssistantEntry { readonly kind: 'assistant' @@ -59,13 +54,23 @@ interface Turn { } /** - * Format one complete Session log as Markdown. - * @param input - the latest title and the complete committed event log. - * @returns Markdown ending in exactly one newline; an empty Session returns its title heading only. + * Render one complete committed Session log as Markdown. This is a pure backend + * utility: it needs no mounted plugin, reads no storage, and does not mutate the + * supplied events. The latest `session/title` event supplies the heading; the + * rest of the transcript is derived only from the supplied logical events. + * @param events - complete committed logical event snapshot in seq order, including any inherited prefix. + * @param sourcePath - optional backend-resolved path to the selected Session artifact; emitted verbatim in a fenced text block. + * @returns Markdown ending in exactly one newline; an empty log returns the heading and optional source metadata only. */ -export function formatSessionMarkdown(input: SessionMarkdownInput): string { - const sections = [`# ${normalizedTitle(input.title)}`] - for (const turn of collectTurns(input.events)) { +export function formatSessionMarkdown(events: readonly SessionEvent[], sourcePath?: string): string { + const title = events.findLast(event => event.type === 'session/title')?.data.title + const sections = [`# ${normalizedTitle(title)}`] + if (sourcePath !== undefined && sourcePath !== '') { + sections.push(`Session file: + +${fenced(sourcePath, 'text', false)}`) + } + for (const turn of collectTurns(events)) { const finalIndex = finalReplyIndex(turn.segments) const end = finalIndex < 0 ? turn.segments.length : finalIndex let pending: TurnEntry[] = [] @@ -260,8 +265,8 @@ function jsonArguments(argumentsText: string): string { return JSON.stringify(parsed, null, 2) } -function fenced(text: string, language = ''): string { - const content = text.replace(/\n+$/u, '') +function fenced(text: string, language = '', trimTrailing = true): string { + const content = trimTrailing ? text.replace(/\n+$/u, '') : text let longest = 0 for (const match of content.matchAll(/`+/gu)) longest = Math.max(longest, match[0].length) const fence = '`'.repeat(Math.max(3, longest + 1)) diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index 6483dcff52..2b00a04e9a 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -4,9 +4,11 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import type { MessageId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { apply, inject } from '../src/index.ts' import { SESSION_MARKDOWN_EXPORT_PATH } from '../src/routes.ts' @@ -74,14 +76,19 @@ function query(overrides: { } } -async function mounted(sessionQuery: unknown): Promise<{ +async function mounted( + sessionQuery: unknown, + persistence?: Pick, +): Promise<{ readonly shared: ReturnType readonly connection: HostConnectionService readonly dispose: () => Promise }> { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) ctx.provide('commands', { register: () => () => {} } as never) if (sessionQuery !== undefined) ctx.provide('sessionQuery', sessionQuery as never) + if (persistence !== undefined) ctx.provide('sessionPersistence', persistence as SessionPersistence) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber @@ -109,6 +116,7 @@ describe('Session Markdown Fetch route', () => { expect(markdown).toContain('# Route title from events') expect(markdown).toContain('> Route question') expect(markdown).toContain('Route answer') + expect(markdown).not.toContain('Session file:') expect(markdown.endsWith('\n')).toBe(true) const head = await shared.fetch( @@ -126,6 +134,59 @@ describe('Session Markdown Fetch route', () => { )).status).toBe(404) }) + + it('emits the actual selected artifact path from persistence metadata', async () => { + const sourcePath = '/tmp/project/session.v4.jsonl' + const stat = vi.fn(async (_id: SessionId, _options?: { readonly signal?: AbortSignal }) => ({ + header: HEADER, + revision: SessionPersistenceRevision('test:location'), + location: { kind: 'jsonl', path: sourcePath }, + })) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + const markdown = await response.text() + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const [requestedId, statOptions] = stat.mock.calls[0]! + expect(requestedId).toBe(SESSION_ID) + expect(statOptions?.signal).toBeInstanceOf(AbortSignal) + await dispose() + }) + + it('omits the source block when metadata has no materialized location', async () => { + const stat = vi.fn(async () => ({ + header: HEADER, + revision: SessionPersistenceRevision('test:no-location'), + })) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + expect(await response.text()).not.toContain('Session file:') + await dispose() + }) + + it('sanitizes a metadata read failure as a preparation failure', async () => { + const stat = vi.fn(async () => { + throw new Error('metadata failed at /private/host/session.v4.jsonl') + }) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(500) + const detail = await response.text() + expect(detail).toBe('session markdown export failed to read the stored log') + expect(detail).not.toContain('/private/host') + await dispose() + }) + it('validates the query before reporting missing Markdown services', async () => { const { shared, connection, dispose } = await mounted(undefined) expect((await shared.fetch(request(SESSION_MARKDOWN_EXPORT_PATH), connection.browserPeer)).status).toBe(400) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts index 80fa21c6a6..9b5a0dec74 100644 --- a/packages/session-query/session-log-export/tests/markdown.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' import { formatSessionMarkdown } from '../src/markdown.ts' @@ -10,6 +11,24 @@ const callId = (value: string): ToolCallId => brandString(value) const attachmentId = (value: string): AttachmentId => brandString(value) const sequence = (value: number): SessionSeq => brandNumber(value) +function titleEvent(seq: number, title: string): SessionEvent<'session/title'> { + return { + type: 'session/title', + seq: sequence(seq), + time: seq, + data: { title, messageSeqs: [], source: { kind: 'fallback' } }, + } +} + +function renderWithTitle(input: { + readonly title: string | undefined + readonly events: readonly SessionEvent[] +}): string { + return formatSessionMarkdown(input.title === undefined + ? input.events + : [titleEvent(0, input.title), ...input.events]) +} + function start(seq: number, turn: number): SessionEvent<'turn/start'> { return { type: 'turn/start', seq: sequence(seq), time: seq, data: { turn } } } @@ -134,8 +153,66 @@ function text(value: string): ContentBlock { } describe('formatSessionMarkdown', () => { + + it('selects the latest real session/title event and treats missing or blank titles as untitled', () => { + const events = [ + titleEvent(0, 'Old title'), + start(1, 1), + user(2, 'Question'), + titleEvent(3, 'Latest title'), + assistant(4, [text('Answer')]), + end(5, 1), + ] + expect(formatSessionMarkdown(events)).toContain('# Latest title') + expect(formatSessionMarkdown(events)).not.toContain('# Old title') + + const blank = [titleEvent(0, ' '), start(1, 1), user(2, 'Question'), end(3, 1)] + expect(formatSessionMarkdown(blank)).toBe('# Untitled Session\n\n> Question\n') + expect(formatSessionMarkdown([])).toBe('# Untitled Session\n') + }) + + + it('emits an exact fenced source path when supplied and omits it otherwise', () => { + const events = [ + titleEvent(0, 'Source path'), + start(1, 1), + user(2, 'Question'), + assistant(3, [text('Answer')]), + end(4, 1), + ] + const sourcePath = '/tmp/Session files/```\nsecond' + expect(formatSessionMarkdown(events, sourcePath)).toBe([ + '# Source path', + '', + 'Session file:', + '', + '````text', + sourcePath, + '````', + '', + '> Question', + '', + 'Answer', + '', + ].join('\n')) + expect(formatSessionMarkdown(events, '')).toBe('# Source path\n\n> Question\n\nAnswer\n') + expect(formatSessionMarkdown(events)).toBe('# Source path\n\n> Question\n\nAnswer\n') + }) + + it('does not mutate a frozen logical event snapshot', () => { + const events = Object.freeze([ + Object.freeze(titleEvent(0, 'Frozen')), + Object.freeze(start(1, 1)), + Object.freeze(user(2, 'Question')), + Object.freeze(assistant(3, [text('Answer')])), + Object.freeze(end(4, 1)), + ]) + expect(formatSessionMarkdown(events)).toBe('# Frozen\n\n> Question\n\nAnswer\n') + expect(events).toHaveLength(5) + }) + it('keeps complete chronology across inherited history and a compaction surface replacement', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Chronology', events: [ start(1, 1), @@ -170,7 +247,7 @@ describe('formatSessionMarkdown', () => { }) it('renders a turn with a collapsible commentary/tool section before the final reply', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Turn layout', events: [ start(1, 1), @@ -216,7 +293,7 @@ describe('formatSessionMarkdown', () => { }) it('keeps a mid-turn steering message after the activity that preceded it', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Steering order', events: [ start(1, 1), @@ -264,7 +341,7 @@ describe('formatSessionMarkdown', () => { '', 'After', ].join('\n') - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Authored final', events: [start(1, 1), user(2, 'Show details'), assistant(3, [text(authored)]), end(4, 1)], }) @@ -275,7 +352,7 @@ describe('formatSessionMarkdown', () => { }) it('ignores replacement surface events and keeps original append chronology', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Replacements', events: [ start(1, 1), @@ -313,7 +390,7 @@ describe('formatSessionMarkdown', () => { }) it('emits no empty assistant section and exactly one trailing newline', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Whitespace', events: [ start(1, 1), @@ -329,7 +406,7 @@ describe('formatSessionMarkdown', () => { it('preserves four-space and tab indentation in final and commentary prose', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Indented code', events: [ start(1, 1), @@ -347,7 +424,7 @@ describe('formatSessionMarkdown', () => { }) it('closes unfinished commentary fences before the generated details closer', () => { - const backtick = formatSessionMarkdown({ + const backtick = renderWithTitle({ title: 'Backtick fence', events: [ start(1, 1), @@ -359,7 +436,7 @@ describe('formatSessionMarkdown', () => { }) expect(backtick).toContain('const x = 1\n```\n\n
\n\nAfter backtick') - const tilde = formatSessionMarkdown({ + const tilde = renderWithTitle({ title: 'Tilde fence', events: [ start(1, 1), @@ -371,7 +448,7 @@ describe('formatSessionMarkdown', () => { }) expect(tilde).toContain('const y = 2\n~~~\n\n
\n\nAfter tilde') - const longer = formatSessionMarkdown({ + const longer = renderWithTitle({ title: 'Longer fence', events: [ start(1, 1), @@ -385,7 +462,7 @@ describe('formatSessionMarkdown', () => { }) it('does not treat an invalid backtick info string as an open fence', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Invalid fence', events: [ start(1, 1), @@ -402,7 +479,7 @@ describe('formatSessionMarkdown', () => { }) it('closes an unfinished final fence before the next turn', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Final fence', events: [ start(1, 1), @@ -421,7 +498,7 @@ describe('formatSessionMarkdown', () => { it('strips blank edge lines without removing indented code blank lines', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Edge blanks', events: [ start(1, 1), @@ -435,7 +512,7 @@ describe('formatSessionMarkdown', () => { }) it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Escaping', events: [ start(1, 1), @@ -457,7 +534,7 @@ describe('formatSessionMarkdown', () => { }) it('labels attachments and omits reasoning, system prompts, and injected context', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Hidden input', events: [ start(1, 1), @@ -499,13 +576,13 @@ describe('formatSessionMarkdown', () => { }) it('exports only the heading for a blank Session and escapes title newlines', () => { - expect(formatSessionMarkdown({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') - expect(formatSessionMarkdown({ title: ' ', events: [] })).toBe('# Untitled Session\n') - expect(formatSessionMarkdown({ title: undefined, events: [] })).toBe('# Untitled Session\n') + expect(renderWithTitle({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') + expect(renderWithTitle({ title: ' ', events: [] })).toBe('# Untitled Session\n') + expect(renderWithTitle({ title: undefined, events: [] })).toBe('# Untitled Session\n') }) it('renders tool-only and incomplete turns predictably', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Incomplete', events: [ start(1, 1), @@ -537,7 +614,7 @@ describe('formatSessionMarkdown', () => { }) it('renders assistant-only, user-only, empty, and malformed content predictably', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Edge content', events: [ // A committed assistant message before the first turn marker still @@ -597,7 +674,7 @@ describe('formatSessionMarkdown', () => { }) it('renders an orphan tool result without inventing a call name', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Orphan', events: [ start(1, 1), diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e134bef8d2..d8f2314ebd 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -30,8 +30,8 @@ en: 96498792672f6d50 zh: d767a9c69dced889 /deepseek-ai-dsh-session-persistence-jsonl/use-this-package/reading-the-logs: - en: 3e53fc90a486d373 - zh: 00e6c6e65c56629e + en: 4cec8a6c9f6e488b + zh: 4757440a552db5e3 /deepseek-ai-dsh-session-persistence-jsonl/understand-the-implementation: en: a35407bb25671e7e zh: 6a00f9510c85adcb diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 5782c61468..2de63df367 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -82,7 +82,7 @@ Unloading the backend waits for the consumer unloads it triggers through `sessio ### Reading the logs -Materialized `stat` and `list` snapshots include the format catalog's header classification as `formatStatus`, alongside the translated logical `header`. A read-only preparation leaves the selected historical version unchanged; the status becomes `current` after publication of the successor. Unmaterialized pending Sessions omit `formatStatus`. +Materialized `stat` and `list` snapshots include the format catalog's header classification as `formatStatus`, alongside the translated logical `header`. They also include `location: { kind: 'jsonl', path }` for the selected physical generation. A read-only preparation leaves the selected historical version unchanged; the status becomes `current` after publication of the successor. Unmaterialized pending Sessions omit `formatStatus` and `location`. `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index dbd602afa3..e5494de7d6 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" ### 读取日志 -已实体化的 `stat` 与 `list` 快照在转换后的逻辑 `header` 之外,通过 `formatStatus` 保留格式 catalog 的头部分类。只读准备不会改变所选历史版本;发布后继之后,状态才变为 `current`。尚未实体化的待定 Session 省略 `formatStatus`。 +已实体化的 `stat` 与 `list` 快照在转换后的逻辑 `header` 之外,通过 `formatStatus` 保留格式 catalog 的头部分类。它们还包含选中物理 generation 的 `location: { kind: 'jsonl', path }`。只读准备不会改变所选历史版本;发布后继之后,状态才变为 `current`。尚未实体化的待定 Session 省略 `formatStatus` 与 `location`。 `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 3c3c13589f..f8351fa9ee 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -504,6 +504,7 @@ class JsonlSessionPersistence extends SessionPersistence { ? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`) : fileRevision(identity), sizeBytes: Number(identity.size), + location: { kind: 'jsonl', path: selected.sourcePath }, } } catch (error: unknown) { options?.signal?.throwIfAborted() @@ -552,6 +553,7 @@ class JsonlSessionPersistence extends SessionPersistence { ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`) : fileRevision(identity), sizeBytes: Number(identity.size), + location: { kind: 'jsonl', path: artifact.path }, }) } for (const [id, entry] of pending) { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 760aaa46c3..82f9f5b6c8 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1223,14 +1223,38 @@ describe('JsonlSessionPersistence: immutable format generations', () => { await writeFile(path, `${JSON.stringify(physical)}\n`) const formatStatus = version === SESSION_FORMAT_VERSION ? 'current' : 'migration-required' expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({ - header: { version: SESSION_FORMAT_VERSION }, formatStatus, + header: { version: SESSION_FORMAT_VERSION }, + formatStatus, + location: { kind: 'jsonl', path }, }) const [listed] = await ctx.sessionPersistence.list() expect(listed?.formatStatus).toBe(formatStatus) + expect(listed?.location).toEqual({ kind: 'jsonl', path }) expect(readTally.bySuffix.size).toBe(0) } }) + + it('reports the selected compressed artifact path in stat and list metadata', async () => { + const compressedRoot = await freshRoot() + const compressed = new Context() + try { + await compressed.plugin(JsonlSessionPersistence, { root: compressedRoot, compression: 'zstd' }) + const header = meta('zstd-location', '/work') + const handle = await compressed.sessionPersistence.create(header) + await handle.append(oneTurnLog()) + await handle.flush() + await handle.close() + const path = logPath(compressedRoot, header.cwd, header.id, 'zstd') + expect((await compressed.sessionPersistence.stat(header.id))?.location) + .toEqual({ kind: 'jsonl', path }) + const [listed] = await compressed.sessionPersistence.list() + expect(listed?.location).toEqual({ kind: 'jsonl', path }) + } finally { + await compressed.fiber.dispose() + } + }) + it('selects a retained future generation above readable historical bytes', async () => { const header = meta('future-wins', '/work') const sourcePath = historicalLogPath(root, header.cwd, header.id) @@ -1421,13 +1445,21 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(pendingRows.map(s => s.header.id)).toContain(m.id) expect(pending?.sizeBytes).toBeUndefined() expect(pending?.formatStatus).toBeUndefined() + expect(pending?.location).toBeUndefined() expect(pendingRows.find(s => s.header.id === m.id)?.formatStatus).toBeUndefined() + expect(pendingRows.find(s => s.header.id === m.id)?.location).toBeUndefined() await handle.append(oneTurnLog()) expect((await stat(dir)).isDirectory()).toBe(true) expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) + const materializedPath = rawLogPath(root, '/work', m.id) expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) - expect((await ctx.sessionPersistence.stat(m.id))?.formatStatus).toBe('current') + expect(await ctx.sessionPersistence.stat(m.id)).toMatchObject({ + formatStatus: 'current', + location: { kind: 'jsonl', path: materializedPath }, + }) + expect((await ctx.sessionPersistence.list()).find(s => s.header.id === m.id)?.location) + .toEqual({ kind: 'jsonl', path: materializedPath }) await handle.close() }) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 8dd313345e..bd02807cf7 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -33,8 +33,8 @@ en: 08b09dce0bcf47c8 zh: b6c5926d6e440a7b /deepseek-ai-dsh-session-persistence/use-this-package/failures-and-recovery: - en: 18a5cfd4cfa07751 - zh: 0c9bf65c4a40d599 + en: 3b536d66137e8f2a + zh: e1f0fbbad61f515e /deepseek-ai-dsh-session-persistence/understand-the-implementation: en: 9e303bd09a71fef1 zh: 1f631925081ae101 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 80f3ec7126..9e9446b81b 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -62,7 +62,7 @@ Persistence returns the physically valid log; semantic repair belongs to the rea ### Failures and recovery -For versioned artifacts, `stat` and `list` expose `formatStatus` as `current` or `migration-required`. The separate `header` remains current logical metadata. This status describes storage, not an in-memory preparation or migration task; providers without this information omit it. +For versioned artifacts, `stat` and `list` expose `formatStatus` as `current` or `migration-required`. The separate `header` remains current logical metadata. This status describes storage, not an in-memory preparation or migration task; providers without this information omit it. A materialized snapshot also carries `location` with the backend-resolved selected per-Session artifact path when the backend keeps one; this observational metadata supports diagnostics and export, is not persisted Session content, and is omitted for an unmaterialized pending Session. A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SessionHandle` exposes only current logical records identified by `SESSION_FORMAT_VERSION`; a provider must convert any supported historical storage before returning a handle, and the shipped JSONL provider migrates supported historical generations through its static catalog. A newer format instructs the operator to upgrade the harness. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index ae8547aa4e..cc1f2edb82 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -62,7 +62,7 @@ await ctx.sessionPersistence.flush() // backend-wide d ### 失败与恢复 -对于版本化产物,`stat` 与 `list` 通过 `formatStatus` 暴露 `current` 或 `migration-required`。独立的 `header` 仍是当前逻辑元数据。该状态描述存储,不描述内存准备或迁移任务;没有这项信息的提供方省略该字段。 +对于版本化产物,`stat` 与 `list` 通过 `formatStatus` 暴露 `current` 或 `migration-required`。独立的 `header` 仍是当前逻辑元数据。该状态描述存储,不描述内存准备或迁移任务;没有这项信息的提供方省略该字段。已物化快照还包含 `location`,在后端保留独立产物时给出后端解析的选中每 Session 产物路径;这项观察元数据用于诊断与导出,不是持久化 Session 内容,未物化的待定 Session 省略它。 当前构建无法忠实解读的存储日志会被拒绝,并返回指明拒绝方向的错误,绝不会被误读。`SessionHandle` 只暴露由 `SESSION_FORMAT_VERSION` 标识的当前逻辑记录;提供方必须在返回句柄前转换任何受支持的历史存储,随产品交付的 JSONL 提供方会通过静态 catalog 迁移受支持的历史代际。更新的格式会要求操作者升级 harness。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。 diff --git a/packages/session/session-persistence/src/errors.ts b/packages/session/session-persistence/src/errors.ts index 62e232741e..cf4acd60cc 100644 --- a/packages/session/session-persistence/src/errors.ts +++ b/packages/session/session-persistence/src/errors.ts @@ -77,10 +77,10 @@ export class SessionHandleClosedError extends Error { } /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ export interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index f257ca0e1e..951c60b31f 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHandle, SessionAccess } from './handle.ts' +import type { SessionLocation } from './errors.ts' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. @@ -58,6 +59,12 @@ export interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } /** Options for {@link SessionPersistence.create}. */ From aa099a9b4069cdef1a487ac5bbe1098cbdc3f0cc Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 21:47:44 +0800 Subject: [PATCH 09/43] fix(ui-user-questions): keep long questions answerable --- apps/web/tests/question-composer.e2e.ts | 176 +++++++++++++++--- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/HoverCard.tsx | 119 +++++++++--- .../tests/hover-card.client.spec.tsx | 140 ++++++++++++++ .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 100 ++++++++-- .../src/client/QuestionComposer.tsx | 116 ++++++++---- .../ui-user-questions/src/client/locales.ts | 2 + .../question-composer-styles.client.spec.ts | 37 ++++ .../user-questions-composer.client.spec.tsx | 81 +++++++- .../question-composer/composed.expected.md | 4 +- .../late-answered.expected.md | 7 +- .../web/question-composer/ui.expected.md | 4 +- 16 files changed, 682 insertions(+), 116 deletions(-) create mode 100644 packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index eda964da3e..70fa1fd1b6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -69,8 +69,8 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: } // The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only text layout that reproduces a -// collapsed row painting its copy outside its own box. +// verifies that each description remains a single ellipsized line while the +// complete text stays reachable through its information action. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -343,48 +343,64 @@ describe('web e2e: resident question composer round trip', () => { await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } - // Squeezed card: the option rows are the capped card's scroll content, so - // shrinking the seat must push overflow into the option list, never - // collapse a row below the height its own copy needs — a collapsed row - // paints its centered copy outside the row box, over the title and the - // neighbouring rows. Measured on the live composer at seat heights that - // force the cap, then restored for the answer gesture below. Replay only: - // record mode must reach the recording write below, not abort on layout. + // Squeezed card: the question and compact option rows share one scrollport, + // while navigation and submission stay outside it. Measured on the live + // composer at seat heights that force the cap, then restored for the answer + // gesture below. Replay only: record mode must reach the recording write + // below, not abort on layout. if (MODE !== 'record') { const original = page.viewportSize() ?? { width: 1680, height: 1000 } + const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { - await page.setViewportSize({ width: 900, height }) + await page.setViewportSize({ width: 640, height }) const squeeze = await composer.evaluate((card) => { - // Role/ARIA selectors, not the CSS-module class names: the built - // client hashes those. - const rows = [...card.querySelectorAll( - '[role="radio"], [role="checkbox"], [aria-expanded]', - )] + const rows = [...card.querySelectorAll('[data-option-row]')] + const descriptions = [...card.querySelectorAll('[data-option-description]')] const spill = rows.map(row => Math.max(...[...row.children].map((child) => { const box = row.getBoundingClientRect() const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) const list = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') return { rows: rows.length, spill: Math.max(...spill), - // Wrapped option text is what overflows a collapsed row, and a - // scrolling list proves the seat is genuinely capped. Without both, - // the spill assertion would hold vacuously. - wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, + ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, + descriptionLines: Math.max(...descriptions.map((item) => { + const style = getComputedStyle(item) + return item.clientHeight / parseFloat(style.lineHeight) + })), scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + titleInScrollport: list?.contains(title) === true, } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.wrappedRows).toBeGreaterThan(0) - expect(squeeze.scrolls).toBe(true) + expect(squeeze.ellipsized).toBeGreaterThan(0) + expect(squeeze.descriptionLines).toBeLessThan(1.1) + scrollStates.push(squeeze.scrolls) + expect(squeeze.titleInScrollport).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) // Sub-pixel tolerance: every row's copy stays inside its border box. expect(squeeze.spill).toBeLessThan(0.6) } + expect(scrollStates).toContain(true) await page.setViewportSize(original) } + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' + const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) + await blueInfo.hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + await blueInfo.click() + expect(await blue.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the // field instead of scrolling one line, and Shift+Enter breaks the line @@ -415,7 +431,6 @@ describe('web e2e: resident question composer round trip', () => { await custom.fill('') } - const blue = composer.getByRole('checkbox', { name: 'Blue' }) await blue.click() await custom.fill('Include accessibility notes') expect(await blue.getAttribute('aria-checked')).toBe('true') @@ -536,6 +551,123 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' + const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' + const description = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'release', + header: 'Release decision', + question, + detail, + multiSelect: true, + options: [ + { label: 'Explicit install', description }, + { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, + { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await composer.waitFor({ timeout: 30_000 }) + // Browser zoom reduces the CSS-pixel viewport. These dimensions model + // the same 640x420 window at 100%, 125%, and 150% without relying on a + // browser-global zoom preference that Playwright cannot isolate per page. + for (const viewport of [ + { width: 640, height: 420 }, + { width: 512, height: 336 }, + { width: 427, height: 280 }, + ]) { + await page.setViewportSize(viewport) + await expect.poll(() => composer.evaluate((card) => { + const scroll = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') + const submit = [...card.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + if (scroll === null || title === null || submit === undefined) return null + const submitBox = submit.getBoundingClientRect() + return { + footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + scrolls: scroll.scrollHeight > scroll.clientHeight, + titleInScrollport: scroll.contains(title), + } + })).toEqual({ + footerFits: true, + scrolls: true, + titleInScrollport: true, + }) + const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { + const style = getComputedStyle(title) + return title.getBoundingClientRect().height / parseFloat(style.lineHeight) + }) + expect(questionLines).toBeGreaterThan(2) + } + + const detailText = composer.getByText(detail, { exact: true }) + expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) + await detailText.scrollIntoViewIfNeeded() + expect(await detailText.isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) + + const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) + const descriptionId = await info.getAttribute('aria-describedby') + expect(descriptionId).not.toBeNull() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.click() + expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') + expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) + const preview = page.locator('[data-pinned="true"]') + await preview.getByText(description, { exact: true }).waitFor() + const previewBox = await preview.boundingBox() + const constrainedViewport = page.viewportSize() + expect(constrainedViewport).not.toBeNull() + expect(previewBox).not.toBeNull() + expect(previewBox?.x).toBeGreaterThanOrEqual(7.5) + expect(previewBox?.y).toBeGreaterThanOrEqual(7.5) + expect((previewBox?.x ?? 0) + (previewBox?.width ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) + expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + await page.keyboard.press('Escape') + + await composer.getByRole('checkbox', { name: 'Explicit install' }).click() + await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ + value: { + answers: [{ + id: 'release', + selected: ['Explicit install'], + custom: 'Keep the migration steps explicit.', + }], + }, + }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 9e054e13cc..ab03603969 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,6 +92,8 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. + `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 96b9db405a..f02e0e6b22 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -92,6 +92,8 @@ kind: "package-library" `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 +显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 + `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e30d671c36..218d86afc6 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,11 +1,12 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' +import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' +import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -16,10 +17,37 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface DescribedAnchorProps { + 'aria-describedby'?: string | undefined +} + +type HoverCardProps = { + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ + anchor: ReactElement + openOnClick: true + describeAnchor?: boolean +} | { + anchor: ReactNode + openOnClick?: false | undefined + describeAnchor?: never +}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +}) + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * Touch entry does not open a preview or intercept the anchor's action. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.anchor - the hover target (rendered in place inside a wrapper span). With + * openOnClick, pass one focusable element that forwards ARIA props. * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). @@ -30,30 +58,23 @@ const VIEWPORT_MARGIN = 8 * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. + * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, + * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible + * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard({ - anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, -}: { - anchor: ReactNode - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' - widthAnchorRef?: RefObject -} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -})) { +export function HoverCard(props: HoverCardProps) { + const { + content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + } = props + const openOnClick = props.openOnClick === true + const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -71,6 +92,7 @@ export function HoverCard({ const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) + const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -84,10 +106,12 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() + setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) + useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -140,7 +164,7 @@ export function HoverCard({ }, []) useEffect(() => { - if (!open || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -150,7 +174,27 @@ export function HoverCard({ } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, inline, cancelClose, close]) + + useEffect(() => { + if (!openOnClick || !open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' && event.key !== 'Tab') return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + } + clearTimer() + cancelClose() + close() + } + document.addEventListener('keydown', dismiss, true) + return () => { document.removeEventListener('keydown', dismiss, true) } + }, [open, openOnClick, cancelClose, close]) + + useEffect(() => { + if (pinned && !openOnClick) close() + }, [close, openOnClick, pinned]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -238,18 +282,32 @@ export function HoverCard({ } const copyable = copyText !== undefined + const describedAnchor = props.openOnClick === true && describeAnchor + ? cloneElement(props.anchor, { + 'aria-describedby': open + ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') + : props.anchor.props['aria-describedby'], + }) + : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() + if (openOnClick && !disabled && !pinned) { + setPinned(true) + setPhase('open') + return + } close() } const card = open && pos !== null && (
{ - if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } + if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -304,13 +362,16 @@ export function HoverCard({ onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. - if (open) armClose() + // open, matching Menu's shape. Keyboard focus remains an independent + // open trigger after the pointer leaves. + if (open && !pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() }} - onPointerDownCapture={dismissFromAnchor} + onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {anchor} + {describedAnchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 65fbad0a64..1059631263 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -225,6 +225,130 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('pins on click for touch access and keeps the card through pointer leave and blur', () => { + render( + <> +

Existing description

+ details} + content={
card body
} + /> + , + ) + const anchor = screen.getByRole('button', { name: 'details' }) + const wrapper = anchor.parentElement as HTMLElement + const touch = new Event('pointerover', { bubbles: true }) + Object.defineProperty(touch, 'pointerType', { value: 'touch' }) + fireEvent(wrapper, touch) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.getAttribute('data-pinned')).toBe('true') + expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.getByText('card body')).toBeTruthy() + + fireEvent.pointerDown(card) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') + }) + + it('preserves a caller-owned accessible description while click-pinned', () => { + render(<> +

Stable description

+ details} + content={
card body
} + /> + ) + const anchor = screen.getByRole('button', { name: 'details' }) + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + }) + + it('dismisses a click-open card on another click, Escape, or Tab', () => { + const ownerKey = vi.fn() + render(
+ details} content={
card body
} /> +
) + const anchor = screen.getByRole('button', { name: 'details' }) + + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.click(anchor) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + fireEvent.keyDown(anchor, { key: 'a' }) + expect(screen.getByText('card body')).toBeTruthy() + ownerKey.mockClear() + expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) + expect(ownerKey).not.toHaveBeenCalled() + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('drops a click pin when click opening is removed or the card is disabled', () => { + const pinnedCard = (disabled = false) => ( + details} content={
card body
} /> + ) + const hoverCard = details} content={
card body
} /> + const view = render(pinnedCard()) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(pinnedCard(true)) + expect(screen.queryByText('card body')).toBeNull() + + view.rerender(pinnedCard()) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(hoverCard) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('removes click-pin dismissal listeners on unmount', () => { + const view = render( + details} content={
card body
} />, + ) + const registrations = vi.spyOn(document, 'addEventListener') + const removals = vi.spyOn(document, 'removeEventListener') + fireEvent.click(screen.getByRole('button', { name: 'details' })) + expect(screen.getByText('card body')).toBeTruthy() + + const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') + expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) + view.unmount() + + for (const [type, listener, options] of listeners) { + const removed = removals.mock.calls.find(([removedType, removedListener]) => + removedType === type && removedListener === listener) + expect(removed).toBeDefined() + expect(removed?.[2]).toBe(options) + } + expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) + fireEvent.pointerDown(document.body) + }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -656,6 +780,22 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) +it('keeps an inline preview open when the pointer leaves its focused anchor', () => { + render(details} content={thumbnail} />) + const anchor = screen.getByRole('button', { name: 'details' }) + const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) + fireEvent.focus(anchor) + expect(screen.getByText('thumbnail')).toBeTruthy() + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('thumbnail')).toBeTruthy() + activeElement.mockRestore() + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.queryByText('thumbnail')).toBeNull() + matches.mockRestore() +}) + it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 78ff5697f1..b161fa2b89 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: cc19c065d0aeac1e - zh: 91c8cbe08b438e7f + en: 05caa3aa1846dc07 + zh: 8055a578d71d3f58 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index d57e359de7..410455e5a8 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2145663976..9a20a9e5f4 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 8de8c84028..5c6d8c013b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -60,6 +60,7 @@ align-items: center; gap: 4px; flex-shrink: 0; + margin-left: auto; } .waitButton { @@ -85,6 +86,7 @@ } .headingBlock { + flex: 1; min-width: 0; } @@ -97,14 +99,24 @@ } .title { - margin: 0; + flex-shrink: 0; + margin: 5px 24px 0; font-size: 16px; line-height: 22px; font-weight: 500; } +.cardMinimized .title { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail { - margin: 0 2px 8px; + flex-shrink: 0; + margin: 0 14px 8px; } .footerActions { @@ -175,29 +187,35 @@ padding: 4px 12px; } +.optionRow { + position: relative; + width: 100%; + min-height: 40px; + flex-shrink: 0; + border: 0.5px solid transparent; + border-radius: var(--dsw-radius-md); + background: transparent; + transition: background-color 120ms ease, border-color 120ms ease; +} + .option { display: flex; - /* flex-start, not center: with a wrapped description the indicator must - stay on the FIRST line (centering drifts it down the taller copy block). - The 8px padding makes a single-line row 40px exactly, so nothing reads - as top-heavy; .number/.checkbox re-center against the first line box. */ + /* flex-start keeps the indicator aligned with the first label line. */ align-items: flex-start; gap: 8px; width: 100%; + min-width: 0; min-height: 40px; - /* Rows are the scroll content, never the slack absorber: a shrinkable row - collapses to min-height while its wrapped copy keeps the taller - intrinsic height, and centered content then paints outside the row box — - over the title and the next row. Overflow belongs to .options. */ - flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; - border-radius: var(--dsw-radius-md); + border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; +} + +.optionWithDescription { + padding-right: 42px; } .optionSelected { @@ -205,7 +223,7 @@ } @media (hover: hover) { - .option:hover:not(:disabled) { + .optionRowInteractive:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -305,10 +323,48 @@ } .description { + display: block; + min-width: 0; + overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.descriptionAction { + position: absolute; + right: 12px; + bottom: 6px; + width: 24px; + height: 24px; +} + +.descriptionButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.descriptionButton:hover, +.descriptionButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.descriptionPreview { + font-size: 13px; + line-height: 20px; } /* Read-only note standing in for a question the user skipped: it takes the @@ -477,6 +533,14 @@ padding: 8px 6px; } + .optionWithDescription { + padding-right: 36px; + } + + .descriptionAction { + right: 6px; + } + .footer { align-items: flex-end; padding: 0 10px; @@ -488,7 +552,7 @@ } @media (prefers-reduced-motion: reduce) { - .option, + .optionRow, .customRow { transition: none; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 8d3ad551ba..d447f96840 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,12 +1,12 @@ import { - useEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -157,6 +157,7 @@ type QuestionFlowProps = { pending: PendingQuestion } & Pick function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) { + const descriptionIdPrefix = useId() const questions = pending.questions // A read-only card built from a settled call's transcript: the same panel // over the recorded answers, with nothing left to submit. @@ -436,12 +437,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-labelledby={`question-${pending.key}-${String(index)}`} >
-
- {question.header !== undefined &&
{question.header}
} -

- {question.question} -

-
+ {(question.header !== undefined || minimized) && ( +
+ {question.header !== undefined &&
{question.header}
} + {minimized && ( +

+ {question.question} +

+ )} +
+ )}
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && ( @@ -486,12 +491,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
+

+ {question.question} +

{question.detail !== undefined && (
)} @@ -499,40 +508,73 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {(question.options ?? []).map((option, optionIndex) => { const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) + const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return ( - + + {hasDescription && ( + + + + + )} + content={} + /> + + )} +
) })} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index d45fd56b4a..26aa017cc9 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -63,6 +64,7 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts new file mode 100644 index 0000000000..1bb0bb553e --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const stylesheet = readFileSync( + fileURLToPath(new URL('../src/client/QuestionComposer.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('QuestionComposer compact long-copy layout', () => { + it('keeps the option description to one ellipsized line', () => { + expect(declarations('.description')).toEqual(expect.arrayContaining([ + 'display: block', + 'min-width: 0', + 'overflow: hidden', + 'font-size: 13px', + 'line-height: 20px', + 'text-overflow: ellipsis', + 'white-space: nowrap', + ])) + }) + + it('keeps the question and choices in the card scrollport', () => { + expect(declarations('.body')).toEqual(expect.arrayContaining([ + 'min-height: 0', + 'overflow-y: auto', + ])) + expect(declarations('.title')).toContain('flex-shrink: 0') + }) +}) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 8ece0da777..c5ce1dc757 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -24,7 +24,13 @@ afterEach(cleanup) const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => - (key => dict[key] ?? common[key] ?? key) + (key, params) => { + const template = dict[key] ?? common[key] ?? key + return params === undefined + ? template + : template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } type SessionState = Parameters[0]>[0] type ConversationState = Parameters[0]>[0] @@ -257,6 +263,7 @@ describe('QuestionComposer', () => { const detail = screen.getByText('按当前空缺岗位的优先级选择。') const scrollRegion = detail.closest('[data-question-scroll]') expect(scrollRegion).toBeTruthy() + expect(scrollRegion?.contains(screen.getByRole('heading', { name: '选择候选人类型' }))).toBe(true) expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -293,6 +300,77 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('opens a full option description without choosing the option', () => { + const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' + const { carrier } = wait([{ + id: 'release', question: 'How should the release graph change?', + options: [ + { label: 'Explicit install', description }, + { label: 'Optional bundle' }, + { label: 'Empty description', description: '' }, + { label: 'Whitespace description', description: ' ' }, + ], + }]) + render() + + const option = screen.getByRole('radio', { name: 'Explicit install' }) + const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) + expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) + expect(screen.getAllByText(description)).toHaveLength(1) + const describedBy = details.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe(description) + fireEvent.click(details) + + expect(option.getAttribute('aria-checked')).toBe('false') + expect(details.getAttribute('aria-describedby')).toBe(describedBy) + expect(screen.getAllByText(description)).toHaveLength(2) + fireEvent.pointerDown(document.body) + expect(screen.getAllByText(description)).toHaveLength(1) + }) + + it('resets scrolling and an open option description when the question changes', () => { + const firstDescription = 'First question detail that was pinned open.' + const secondDescription = 'Second question detail starts closed.' + const { carrier } = wait([ + { + id: 'first', question: 'First question?', + options: [{ label: 'Continue', description: firstDescription }], + }, + { + id: 'second', question: 'Second question?', + options: [{ label: 'Continue', description: secondDescription }], + }, + ]) + render() + + expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() + const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement + firstScroll.scrollTop = 80 + const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + fireEvent.click(firstDetails) + expect(screen.getAllByText(firstDescription)).toHaveLength(2) + const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(firstDescribedIds.length).toBeGreaterThan(0) + expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + + const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement + expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(secondScroll).not.toBe(firstScroll) + expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByText(firstDescription)).toBeNull() + expect(screen.getAllByText(secondDescription)).toHaveLength(1) + const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(describedIds.length).toBeGreaterThan(0) + expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', @@ -863,6 +941,7 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) + expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index 2b62217f60..e5a7b73860 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -1,11 +1,13 @@ - region "Which color do you prefer?": - text: Pick one - - heading "Which color do you prefer?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index a2e1961f8d..49ac47eeb4 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -29,14 +29,15 @@ - text: {{clock}} - button "Copy" - region "Which color do you prefer?": - - text: Pick one - - heading "Which color do you prefer?" [level=2] - - text: Answered + - text: Pick one Answered - button "Collapse the question card" [expanded] - button "Close the panel — reopen it from the tool call" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [disabled]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index e975922565..3188d73c5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -1,11 +1,13 @@ - region "Which color do you prefer?": - text: Pick one - - heading "Which color do you prefer?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 1cbf50d03e536ce322873bfa17bdbe992842184d Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 22:31:04 +0800 Subject: [PATCH 10/43] test(web): stress long question layouts --- apps/web/tests/question-composer.e2e.ts | 49 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 70fa1fd1b6..73b77e9fc5 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -559,7 +559,8 @@ describe('web e2e: resident question composer round trip', () => { expect(agent).toBeDefined() const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' - const description = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + const description = Array.from({ length: 48 }, (_, index) => + `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') const controller = new AbortController() const asked = scaffold.ctx.userQuestions.ask({ agent: agent as NonNullable, @@ -584,16 +585,18 @@ describe('web e2e: resident question composer round trip', () => { let bodyCompleted = false try { await composer.waitFor({ timeout: 30_000 }) - // Browser zoom reduces the CSS-pixel viewport. These dimensions model - // the same 640x420 window at 100%, 125%, and 150% without relying on a - // browser-global zoom preference that Playwright cannot isolate per page. + // Cover common desktop sizes plus the CSS-pixel equivalents of a + // progressively zoomed 640x420 window without changing browser-global zoom. + const scrollStates: boolean[] = [] for (const viewport of [ + { width: 1440, height: 900 }, + { width: 900, height: 600 }, { width: 640, height: 420 }, { width: 512, height: 336 }, { width: 427, height: 280 }, ]) { await page.setViewportSize(viewport) - await expect.poll(() => composer.evaluate((card) => { + const readMetrics = () => composer.evaluate((card) => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] @@ -605,17 +608,20 @@ describe('web e2e: resident question composer round trip', () => { scrolls: scroll.scrollHeight > scroll.clientHeight, titleInScrollport: scroll.contains(title), } - })).toEqual({ - footerFits: true, - scrolls: true, - titleInScrollport: true, }) + await expect.poll(readMetrics).not.toBeNull() + const metrics = await readMetrics() + expect(metrics?.footerFits).toBe(true) + expect(metrics?.titleInScrollport).toBe(true) + scrollStates.push(metrics?.scrolls ?? false) const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) } + expect(scrollStates).toContain(false) + expect(scrollStates).toContain(true) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -632,6 +638,13 @@ describe('web e2e: resident question composer round trip', () => { expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) const preview = page.locator('[data-pinned="true"]') await preview.getByText(description, { exact: true }).waitFor() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { + const style = getComputedStyle(item) + return { + ellipsized: item.scrollWidth > item.clientWidth, + lines: item.clientHeight / parseFloat(style.lineHeight), + } + })).toEqual({ ellipsized: true, lines: 1 }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() @@ -642,6 +655,24 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + expect(await preview.evaluate(card => ({ + overflowY: getComputedStyle(card).overflowY, + scrolls: card.scrollHeight > card.clientHeight, + }))).toEqual({ overflowY: 'auto', scrolls: true }) + expect(await preview.getByText(description, { exact: true }).evaluate((text) => { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(text) + selection?.removeAllRanges() + selection?.addRange(range) + const selectedLength = selection?.toString().length ?? 0 + selection?.removeAllRanges() + return selectedLength + })).toBe(description.length) + expect(await preview.evaluate((card) => { + card.scrollTop = card.scrollHeight + return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 + })).toBe(true) await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 5b96747630b7d82f4888ba71f377e999394a08e7 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 23:09:03 +0800 Subject: [PATCH 11/43] fix(ui-user-questions): refine option detail callouts --- apps/web/tests/question-composer.e2e.ts | 32 +++++- .../ui-primitives/src/HoverCard.module.css | 50 ++++++++++ .../client/ui-primitives/src/HoverCard.tsx | 98 ++++++++++++++----- .../tests/hover-card.client.spec.tsx | 41 +++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 3 + .../src/client/QuestionComposer.tsx | 2 +- 9 files changed, 204 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 73b77e9fc5..d0c71ab3ab 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -633,6 +633,15 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await info.getAttribute('aria-describedby') expect(descriptionId).not.toBeNull() expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.hover() + const hoveredPreview = page.locator('[data-placement]') + await hoveredPreview.getByText(description, { exact: true }).waitFor() + const hoveredBox = await hoveredPreview.boundingBox() + expect(hoveredBox).not.toBeNull() + await page.mouse.move((hoveredBox?.x ?? 0) + 24, (hoveredBox?.y ?? 0) + 24) + await page.waitForTimeout(250) + expect(await hoveredPreview.isVisible()).toBe(true) + await info.click() expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) @@ -655,10 +664,29 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) - expect(await preview.evaluate(card => ({ + expect(previewBox?.height).toBeLessThanOrEqual(240) + const scrollport = preview.locator('[data-hover-card-scroll]') + expect(await scrollport.evaluate(card => ({ overflowY: getComputedStyle(card).overflowY, scrolls: card.scrollHeight > card.clientHeight, }))).toEqual({ overflowY: 'auto', scrolls: true }) + const caretMetrics = await preview.evaluate((card, infoButton) => { + const caret = card.querySelector('[data-hover-card-caret]') + if (!(infoButton instanceof HTMLElement) || caret === null) return null + const anchorBox = infoButton.getBoundingClientRect() + const caretBox = caret.getBoundingClientRect() + const placement = card.dataset.placement + return { + horizontalDelta: Math.abs((anchorBox.left + anchorBox.width / 2) - (caretBox.left + caretBox.width / 2)), + verticalGap: placement === 'above' + ? anchorBox.top - caretBox.bottom + : caretBox.top - anchorBox.bottom, + } + }, await info.elementHandle()) + expect(caretMetrics).not.toBeNull() + expect(caretMetrics?.horizontalDelta).toBeLessThan(1) + expect(caretMetrics?.verticalGap).toBeGreaterThanOrEqual(0) + expect(caretMetrics?.verticalGap).toBeLessThanOrEqual(3) expect(await preview.getByText(description, { exact: true }).evaluate((text) => { const selection = window.getSelection() const range = document.createRange() @@ -669,7 +697,7 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await preview.evaluate((card) => { + expect(await scrollport.evaluate((card) => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..0542763a8a 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,53 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.callout { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + isolation: isolate; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.calloutSurface { + position: relative; + z-index: 1; + padding: 4px; + overflow: hidden; + border: 0; + border-radius: var(--dsw-radius-xl); + background: var(--dsw-specific-bubble); + box-shadow: var(--dsw-elevation-panel); +} + +.calloutBody { + box-sizing: border-box; + max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 8px; + border-radius: var(--dsw-radius-lg); + color: var(--dsw-alias-label-primary); +} + +.calloutCaret { + position: absolute; + z-index: 2; + left: var(--dsh-hover-card-anchor-x); + width: 10px; + height: 10px; + background: var(--dsw-specific-bubble); + transform: translateX(-50%) rotate(45deg); + pointer-events: none; +} + +.callout[data-placement='below'] .calloutCaret { + top: -4px; +} + +.callout[data-placement='above'] .calloutCaret { + bottom: -4px; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 218d86afc6..6f6d7288e7 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -14,6 +14,10 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 +const CALLOUT_MAX_HEIGHT = 240 +const CALLOUT_WIDTH = 300 +const CALLOUT_ARROW_EDGE_INSET = 16 +const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 @@ -28,7 +32,7 @@ type HoverCardProps = { openDelayMs?: number disabled?: boolean grouped?: boolean - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' widthAnchorRef?: RefObject } & ({ anchor: ReactElement @@ -51,8 +55,8 @@ type HoverCardProps = { * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * @param props.variant - compact card beside the anchor; a preview with container-relative + * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. @@ -74,9 +78,12 @@ export function HoverCard(props: HoverCardProps) { copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, } = props const openOnClick = props.openOnClick === true + const callout = variant === 'callout' + const anchored = inline || callout const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) + const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -86,7 +93,14 @@ export function HoverCard(props: HoverCardProps) { const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState<{ + left: number + top: number + width?: number + maxHeight?: number + anchorX?: number + placement?: 'above' | 'below' + } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -164,7 +178,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !anchored)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -174,7 +188,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, anchored, cancelClose, close]) useEffect(() => { if (!openOnClick || !open) return @@ -222,19 +236,34 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (inline) { - const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + if (anchored) { + const height = callout + ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) + : Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, + window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = onTop ? above : below + const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, + onTop ? above : below) + const anchorCenter = (r.left + r.right) / 2 + const idealLeft = callout ? anchorCenter - width / 2 : r.left + const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, + window.innerWidth - width - VIEWPORT_MARGIN)) + const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), + left, top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, + ...(callout + ? { + anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), + placement: onTop ? 'above' as const : 'below' as const, + } + : {}), }) return } @@ -242,8 +271,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -253,19 +282,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, anchored, callout]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || inline) return + if (!open || pos === null || variant === 'preview' || anchored) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, inline]) + }, [open, pos, variant, anchored]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -301,16 +330,23 @@ export function HoverCard(props: HoverCardProps) { } close() } + const cardContent = copied ? : content const card = open && pos !== null && (
{ + cancelClose() + setPhase('open') + }} + onPointerLeave={() => { + if (!pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() + }} > - {copied ? : content} + {callout + ? ( + <> +
) return ( { + className={clsx(css.root, anchored && css.inline)} + onFocus={anchored ? (event) => { if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={inline ? (event) => { + onBlur={anchored ? (event) => { if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 1059631263..fa51eef33a 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,6 +165,27 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement + expect(card.style.width).toBe('300px') + expect(card.style.left).toBe('8px') + expect(card.style.top).toBe('82px') + expect(card.dataset.placement).toBe('below') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') + + Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('452px') + expect(card.dataset.placement).toBe('above') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + }) + it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -254,6 +275,9 @@ describe('HoverCard', () => { fireEvent.blur(anchor, { relatedTarget: document.body }) expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(card) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(document.body) @@ -425,6 +449,21 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('keeps the card open while its own pointer is inside and closes after leaving it', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b161fa2b89..5d9e49a032 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 05caa3aa1846dc07 - zh: 8055a578d71d3f58 + en: 579cf44ad03380ee + zh: 5fe535a78d8ed0b8 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 410455e5a8..f4b7b4abbc 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 9a20a9e5f4..73ab6e76a5 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 5c6d8c013b..996272b543 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -363,8 +363,11 @@ } .descriptionPreview { + display: block; font-size: 13px; line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; } /* Read-only note standing in for a question the user skipped: it takes the diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index d447f96840..c813d9b17b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -556,7 +556,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {hasDescription && ( Date: Thu, 1 Oct 2026 23:39:42 +0800 Subject: [PATCH 12/43] test(web): verify question callout in dark mode --- apps/web/tests/question-composer.e2e.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d0c71ab3ab..1bd0552f07 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -701,6 +701,44 @@ describe('web e2e: resident question composer round trip', () => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) + const palette: string[] = [] + const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + for (const dark of [false, true]) { + await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark) + const colors = await preview.evaluate((card) => { + const surface = card.querySelector('[class*="calloutSurface"]') + const body = card.querySelector('[data-hover-card-scroll]') + const caret = card.querySelector('[data-hover-card-caret]') + if (surface === null || body === null || caret === null) return null + return { + surface: getComputedStyle(surface).backgroundColor, + text: getComputedStyle(body).color, + caret: getComputedStyle(caret).backgroundColor, + } + }) + expect(colors).not.toBeNull() + expect(colors?.surface).toBe(colors?.caret) + if (colors === null) throw new Error('Callout colors are unavailable') + const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [] + const luminance = (color: string) => channels(color).reduce((total, channel, index) => { + const normalized = channel / 255 + const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + return total + linear * [0.2126, 0.7152, 0.0722][index]! + }, 0) + const foreground = luminance(colors.text) + const background = luminance(colors.surface) + expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) + .toBeGreaterThanOrEqual(4.5) + palette.push(colors.surface) + } + expect(palette[0]).not.toBe(palette[1]) + } finally { + await page.evaluate((previous) => { + if (previous === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', previous) + }, originalDarkTheme) + } await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 935a891a45ad1770ac239f84acc38d9fdc9302d6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 00:35:59 +0800 Subject: [PATCH 13/43] fix(ui-user-questions): preserve inline short option descriptions --- apps/web/tests/question-composer.e2e.ts | 77 ++++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 12 ++- .../src/client/QuestionComposer.tsx | 76 ++++++++++++++++-- .../question-composer-styles.client.spec.ts | 8 ++ .../question-composer/composed.expected.md | 2 - .../late-answered.expected.md | 2 - .../web/question-composer/ui.expected.md | 2 - 10 files changed, 163 insertions(+), 24 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 1bd0552f07..3df60287d9 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -348,8 +348,8 @@ describe('web e2e: resident question composer round trip', () => { // composer at seat heights that force the cap, then restored for the answer // gesture below. Replay only: record mode must reach the recording write // below, not abort on layout. + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') { - const original = page.viewportSize() ?? { width: 1680, height: 1000 } const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { await page.setViewportSize({ width: 640, height }) @@ -385,9 +385,10 @@ describe('web e2e: resident question composer round trip', () => { expect(squeeze.spill).toBeLessThan(0.6) } expect(scrollStates).toContain(true) - await page.setViewportSize(original) + await page.setViewportSize(originalViewport) } + await page.setViewportSize({ width: 640, height: 520 }) const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) @@ -400,6 +401,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + await page.setViewportSize(originalViewport) // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the @@ -551,6 +553,68 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => { + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'reasoning', + header: '确认思考等级', + question: '选择 HF provider 的思考等级?', + options: [ + { label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' }, + { label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' }, + { label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' }, + { label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let completed = false + try { + await page.setViewportSize({ width: 1328, height: 720 }) + await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() + expect(await composer.getByRole('heading').evaluate((heading) => { + const style = getComputedStyle(heading) + return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) + })).toBe(1) + const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { + const label = row.querySelector('[data-option-line] > span:first-child') + const description = row.querySelector('[data-option-description]') + if (label === null || description === null) return null + const style = getComputedStyle(description) + return { + sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + hasDetails: row.querySelector('[data-option-details]') !== null, + } + })) + expect(rows).toHaveLength(4) + expect(rows).toEqual(Array.from({ length: 4 }, () => ({ + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + }))) + await composer.getByRole('radio', { name: '中(medium)' }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } }) + completed = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (completed) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) const sessionId = answeredSession @@ -573,8 +637,8 @@ describe('web e2e: resident question composer round trip', () => { multiSelect: true, options: [ { label: 'Explicit install', description }, - { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, - { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, ], }], }) @@ -614,6 +678,11 @@ describe('web e2e: resident question composer round trip', () => { expect(metrics?.footerFits).toBe(true) expect(metrics?.titleInScrollport).toBe(true) scrollStates.push(metrics?.scrolls ?? false) + expect(await composer.getByRole('button', { name: 'Show details for “Keep the default edge”' }).count()).toBe(0) + if (viewport.width === 1440 || viewport.width === 427) { + expect(await composer.getByRole('button', { name: 'Show details for “Move the package”' }).count()) + .toBe(viewport.width === 427 ? 1 : 0) + } const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 5d9e49a032..9ba61f789c 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 579cf44ad03380ee - zh: 5fe535a78d8ed0b8 + en: 1087967ef7402ed0 + zh: e7edbbb967b98af4 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index f4b7b4abbc..eb3bf89735 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; only descriptions truncated there show an information action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 73ab6e76a5..d60ebfc784 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时显示信息按钮,悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 996272b543..994b8d3102 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -188,6 +188,7 @@ } .optionRow { + --description-action-space: 30px; position: relative; width: 100%; min-height: 40px; @@ -215,7 +216,7 @@ } .optionWithDescription { - padding-right: 42px; + padding-right: calc(12px + var(--description-action-space)); } .optionSelected { @@ -334,6 +335,13 @@ white-space: nowrap; } +.descriptionInline { + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + font-weight: 400; +} + .descriptionAction { position: absolute; right: 12px; @@ -537,7 +545,7 @@ } .optionWithDescription { - padding-right: 36px; + padding-right: calc(6px + var(--description-action-space)); } .descriptionAction { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index c813d9b17b..baa439b40e 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,5 +1,5 @@ import { - useEffect, useId, useMemo, useRef, useState, + useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' @@ -242,6 +242,60 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + const [descriptionModes, setDescriptionModes] = useState | null>(null) + + useLayoutEffect(() => { + const surface = answerSurface.current + if (surface === null || minimized) return + const rows = [...surface.querySelectorAll('[data-option-row]')] + const measure = (): void => { + const next = new Map() + for (const row of rows) { + const description = row.querySelector('[data-option-description]') + if (description === null) continue + const optionIndex = Number(row.dataset.optionIndex) + const copy = row.querySelector('[data-option-copy]') + const line = row.querySelector('[data-option-line]') + if (copy === null || line === null || copy.clientWidth === 0) { + next.set(optionIndex, 'truncated') + continue + } + const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space')) + // The action's reserved width must not make a fitting description appear truncated. + const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace) + const probe = line.cloneNode(true) as HTMLElement + probe.querySelector('[data-option-description]')?.remove() + const probeDescription = description.cloneNode(true) as HTMLElement + probeDescription.removeAttribute('id') + probeDescription.className = clsx(css.descriptionInline) + probe.append(probeDescription) + probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none' + document.body.append(probe) + const inlineWidth = probe.getBoundingClientRect().width + probeDescription.className = clsx(css.description) + probeDescription.style.width = 'max-content' + const blockWidth = probeDescription.getBoundingClientRect().width + probe.remove() + next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline' + : blockWidth <= fullWidth + 1 ? 'block' : 'truncated') + } + setDescriptionModes(current => current !== null && current.size === next.size + && [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next) + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + for (const row of rows) { + observer?.observe(row) + const copy = row.querySelector('[data-option-copy]') + if (copy !== null) observer?.observe(copy) + } + const fonts = 'fonts' in document ? document.fonts : null + fonts?.addEventListener('loadingdone', measure) + return () => { + observer?.disconnect() + fonts?.removeEventListener('loadingdone', measure) + } + }, [index, minimized, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -509,20 +563,23 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated' + const showDetails = hasDescription && descriptionMode === 'truncated' const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {hasDescription && ( - + {showDetails && ( + { + it('keeps fitting descriptions in the original option-line typography', () => { + expect(declarations('.descriptionInline')).toEqual(expect.arrayContaining([ + 'font-size: 14px', + 'line-height: 24px', + 'font-weight: 400', + ])) + }) + it('keeps the option description to one ellipsized line', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index e5a7b73860..830627029d 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -5,9 +5,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index 49ac47eeb4..91f1ca73c2 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -35,9 +35,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [disabled]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 3188d73c5b..99c6c0e4bb 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -5,9 +5,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 9d44ecefb74430651567e677a822eabac492cd01 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 01:12:36 +0800 Subject: [PATCH 14/43] style(ui-user-questions): fade truncated option details --- apps/web/tests/question-composer.e2e.ts | 23 ++++++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 ++-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 15 +++++++++++- .../src/client/QuestionComposer.tsx | 10 +++++--- .../question-composer-styles.client.spec.ts | 7 ++++-- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 3df60287d9..f16855916c 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -68,9 +68,8 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: }) } -// The options carry long descriptions on purpose: the squeeze assertion below -// verifies that each description remains a single ellipsized line while the -// complete text stays reachable through its information action. +// The options carry descriptions on purpose: the squeeze assertion below +// verifies that each description remains on one line in the card scrollport. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -366,7 +365,6 @@ describe('web e2e: resident question composer round trip', () => { return { rows: rows.length, spill: Math.max(...spill), - ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, descriptionLines: Math.max(...descriptions.map((item) => { const style = getComputedStyle(item) return item.clientHeight / parseFloat(style.lineHeight) @@ -376,7 +374,6 @@ describe('web e2e: resident question composer round trip', () => { } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.ellipsized).toBeGreaterThan(0) expect(squeeze.descriptionLines).toBeLessThan(1.1) scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -595,11 +592,12 @@ describe('web e2e: resident question composer round trip', () => { fontSize: style.fontSize, lineHeight: style.lineHeight, hasDetails: row.querySelector('[data-option-details]') !== null, + maskImage: style.maskImage, } })) expect(rows).toHaveLength(4) expect(rows).toEqual(Array.from({ length: 4 }, () => ({ - sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none', }))) await composer.getByRole('radio', { name: '中(medium)' }).click() await composer.getByRole('button', { name: 'Submit', exact: true }).click() @@ -719,10 +717,19 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { const style = getComputedStyle(item) return { - ellipsized: item.scrollWidth > item.clientWidth, lines: item.clientHeight / parseFloat(style.lineHeight), + textOverflow: style.textOverflow, + fades: style.maskImage !== 'none', } - })).toEqual({ ellipsized: true, lines: 1 }) + })).toEqual({ lines: 1, textOverflow: 'clip', fades: true }) + expect(await info.evaluate((button) => { + const icon = button.querySelector('svg') + const ring = icon?.parentElement + return { + dots: icon?.querySelectorAll('path').length, + ring: ring instanceof HTMLElement ? getComputedStyle(ring).borderStyle : undefined, + } + })).toEqual({ dots: 3, ring: 'solid' }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 9ba61f789c..89fe1a97f6 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 1087967ef7402ed0 - zh: e7edbbb967b98af4 + en: 9a8f58ca1fcd87db + zh: be2587dc4bd54aa6 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index eb3bf89735..8f8fa0e12c 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; only descriptions truncated there show an information action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index d60ebfc784..0c0ed83103 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时显示信息按钮,悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 994b8d3102..9d11ea12da 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -331,10 +331,14 @@ font-size: 13px; line-height: 20px; font-weight: 400; - text-overflow: ellipsis; + text-overflow: clip; white-space: nowrap; } +.descriptionTruncated { + mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); +} + .descriptionInline { color: var(--dsw-alias-label-tertiary); font-size: 14px; @@ -370,6 +374,15 @@ color: var(--dsw-alias-label-primary); } +.descriptionMoreIcon { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: 1px solid currentColor; + border-radius: 50%; +} + .descriptionPreview { display: block; font-size: 13px; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index baa439b40e..7fbe2e74d6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, + HoverCard, IconEditOutlineRegular, IconEllipsisOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -609,7 +609,11 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi )} {hasDescription && descriptionMode !== 'inline' && ( - {option.description} + {option.description} )} @@ -627,7 +631,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={t('option.details', { option: display.label })} aria-describedby={descriptionId} > - + )} content={} diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 13c942868b..d55618b7e5 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -23,16 +23,19 @@ describe('QuestionComposer compact long-copy layout', () => { ])) }) - it('keeps the option description to one ellipsized line', () => { + it('keeps the option description to one clipped line and fades only truncated text', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', 'min-width: 0', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: ellipsis', + 'text-overflow: clip', 'white-space: nowrap', ])) + expect(declarations('.descriptionTruncated')).toContain( + 'mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent)', + ) }) it('keeps the question and choices in the card scrollport', () => { From fbc2550f52b5799bed9c4d39cb098fbca2efd20a Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 13:45:34 +0800 Subject: [PATCH 15/43] fix(ui-user-questions): preview long questions in three lines --- apps/web/tests/question-composer.e2e.ts | 35 ++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 28 +++++++++++++ .../src/client/QuestionComposer.tsx | 39 +++++++++++++++++-- .../ui-user-questions/src/client/locales.ts | 2 + .../user-questions-composer.client.spec.tsx | 27 +++++++++++++ 8 files changed, 130 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f16855916c..96e88fb512 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -582,6 +582,8 @@ describe('web e2e: resident question composer round trip', () => { const style = getComputedStyle(heading) return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) })).toBe(1) + expect(await composer.getByRole('button', { name: 'Show full question' }).count()).toBe(0) + expect(await composer.getByRole('heading').evaluate(heading => getComputedStyle(heading).maskImage)).toBe('none') const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -619,7 +621,7 @@ describe('web e2e: resident question composer round trip', () => { expect(sessionId).toBeDefined() const agent = scaffold.ctx.agents.get(sessionId as SessionId) expect(agent).toBeDefined() - const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' + const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ') const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' const description = Array.from({ length: 48 }, (_, index) => `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') @@ -686,10 +688,41 @@ describe('web e2e: resident question composer round trip', () => { return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) + expect(questionLines).toBeLessThanOrEqual(3.05) + const truncated = await composer.getByRole('heading', { name: question }).evaluate(title => + title.scrollHeight > title.clientHeight + 1) + await expect.poll(() => composer.getByRole('button', { name: 'Show full question' }).count()) + .toBe(truncated ? 1 : 0) } expect(scrollStates).toContain(false) expect(scrollStates).toContain(true) + const questionInfo = composer.getByRole('button', { name: 'Show full question' }) + await questionInfo.scrollIntoViewIfNeeded() + const questionTitle = composer.getByRole('heading', { name: question }) + expect(await questionInfo.getAttribute('aria-describedby')).toBe(await questionTitle.getAttribute('id')) + const questionMask = await questionTitle.evaluate(title => getComputedStyle(title).maskImage) + expect(questionMask).toContain('linear-gradient') + await page.keyboard.press('Tab') + await questionInfo.focus() + const questionPreview = page.locator('[data-placement]') + await questionPreview.getByText(question, { exact: true }).waitFor() + expect(await questionPreview.evaluate(node => node.closest('[data-question-scroll]') === null)).toBe(true) + await page.keyboard.press('Escape') + await expect.poll(() => questionPreview.count()).toBe(0) + await questionInfo.click() + await questionPreview.getByText(question, { exact: true }).waitFor() + const previewGeometry = await questionPreview.evaluate((node) => { + const rect = node.getBoundingClientRect() + const scroll = node.querySelector('[data-hover-card-scroll]') + return { fits: rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight, + scrolls: scroll !== null && scroll.scrollHeight > scroll.clientHeight } + }) + expect(previewGeometry.fits).toBe(true) + expect(previewGeometry.scrolls).toBe(true) + await composer.getByRole('heading', { name: question }).click() + await expect.poll(() => questionPreview.count()).toBe(0) + const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) await detailText.scrollIntoViewIfNeeded() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 89fe1a97f6..8608006f15 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 9a8f58ca1fcd87db - zh: be2587dc4bd54aa6 + en: 02c47a01ae6fd4f4 + zh: 7adb34361d02bc9d /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 8f8fa0e12c..83e50f9533 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 0c0ed83103..f92fe5cf37 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 9d11ea12da..89852a34a6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -106,6 +106,34 @@ font-weight: 500; } +/* Keep the full question accessible while only its first three lines are visible. */ +.questionTitle { + position: relative; + flex-shrink: 0; + margin: 5px 24px 0; +} + +.titlePreview { + margin: 0; + max-height: 3lh; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.titleTruncated { + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 74px), transparent calc(100% - 28px)); + mask-size: 100% 2lh, 100% 1lh; + mask-position: top left, bottom left; + mask-repeat: no-repeat; +} + +.questionAction { + position: absolute; + right: 0; + bottom: -1px; +} + .cardMinimized .title { display: -webkit-box; margin: 0; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 7fbe2e74d6..0421a8f718 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -242,13 +242,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + const [questionTruncated, setQuestionTruncated] = useState(false) const [descriptionModes, setDescriptionModes] = useState | null>(null) useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return + const title = surface.querySelector('[data-question-title]') const rows = [...surface.querySelectorAll('[data-option-row]')] const measure = (): void => { + if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1) const next = new Map() for (const row of rows) { const description = row.querySelector('[data-option-description]') @@ -284,6 +287,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi } measure() const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + if (title !== null) observer?.observe(title) for (const row of rows) { observer?.observe(row) const copy = row.querySelector('[data-option-copy]') @@ -295,7 +299,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi observer?.disconnect() fonts?.removeEventListener('loadingdone', measure) } - }, [index, minimized, question.options]) + }, [index, minimized, question.question, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -552,9 +556,36 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi onFocusCapture={focusAnswerSurface} onBlurCapture={blurAnswerSurface} > -

- {question.question} -

+
+

+ {question.question} +

+ {questionTruncated && ( + + + + + )} + content={} + /> + + )} +
{question.detail !== undefined && (
)} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 26aa017cc9..e8e42ac851 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'question.details': '查看完整问题', 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', @@ -64,6 +65,7 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'question.details': 'Show full question', 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c5ce1dc757..34fc7525b6 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -300,6 +300,33 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('opens the full truncated question and removes its action on the next fitting question', () => { + const question = 'First question with four rendered lines' + const scrollHeight = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-question-title') && this.textContent === question ? 88 : 66 + }) + const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(66) + try { + const { carrier } = wait([ + { id: 'first', question, options: [{ label: 'Continue' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue' }] }, + ]) + render() + const details = screen.getByRole('button', { name: '查看完整问题' }) + expect(details.getAttribute('aria-describedby')).toBe(screen.getByRole('heading', { name: question }).id) + fireEvent.click(details) + expect(screen.getAllByText(question)).toHaveLength(2) + expect(screen.getByRole('radio', { name: 'Continue' }).getAttribute('aria-checked')).toBe('false') + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + expect(screen.queryByText(question)).toBeNull() + expect(screen.queryByRole('button', { name: '查看完整问题' })).toBeNull() + } finally { + cleanup() + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + it('opens a full option description without choosing the option', () => { const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' const { carrier } = wait([{ From 50a9e31c2c883c076798571a8201d4b1b218fa02 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 14:59:34 +0800 Subject: [PATCH 16/43] fix(client): preserve question focus and dismiss hover cards --- apps/web/tests/question-composer.e2e.ts | 42 ++++++++++++---- packages/client/ui-primitives/README.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 40 ++++++++++----- .../tests/hover-card.client.spec.tsx | 49 ++++++++++++++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 4 +- .../src/client/QuestionComposer.tsx | 8 ++- .../question-composer-styles.client.spec.ts | 1 + .../user-questions-composer.client.spec.tsx | 24 ++++++++- .../web/question-composer/ui.expected.md | 1 + 12 files changed, 142 insertions(+), 37 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 96e88fb512..db6ae2e128 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -337,7 +337,13 @@ describe('web e2e: resident question composer round trip', () => { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes => + nodes.map(node => ({ + label: node.getAttribute('aria-label'), + description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/) + .filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '), + }))) + await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE) const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } @@ -640,7 +646,7 @@ describe('web e2e: resident question composer round trip', () => { { label: 'Keep the default edge', description: 'Keep the current setup.' }, { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, ], - }], + }, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }], }) const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) @@ -664,7 +670,7 @@ describe('web e2e: resident question composer round trip', () => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] - .find(button => button.textContent === 'Submit') + .find(button => button.textContent === 'Next') if (scroll === null || title === null || submit === undefined) return null const submitBox = submit.getBoundingClientRect() return { @@ -720,6 +726,15 @@ describe('web e2e: resident question composer round trip', () => { }) expect(previewGeometry.fits).toBe(true) expect(previewGeometry.scrolls).toBe(true) + const questionScroll = questionPreview.locator('[data-hover-card-scroll]') + await questionInfo.focus() + await page.keyboard.press('End') + expect(await questionScroll.evaluate(node => node.scrollTop + node.clientHeight >= node.scrollHeight - 1)).toBe(true) + await page.keyboard.press('Home') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBe(0) + await page.keyboard.press('PageDown') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0) + await composer.getByRole('heading', { name: question }).click() await expect.poll(() => questionPreview.count()).toBe(0) @@ -727,7 +742,7 @@ describe('web e2e: resident question composer round trip', () => { expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) await detailText.scrollIntoViewIfNeeded() expect(await detailText.isVisible()).toBe(true) - expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true) const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) const descriptionId = await info.getAttribute('aria-describedby') @@ -806,10 +821,11 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await scrollport.evaluate((card) => { - card.scrollTop = card.scrollHeight - return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 - })).toBe(true) + await info.focus() + await page.keyboard.press('Home') + expect(await scrollport.evaluate(card => card.scrollTop)).toBe(0) + await page.keyboard.press('End') + expect(await scrollport.evaluate(card => card.scrollTop + card.clientHeight >= card.scrollHeight - 1)).toBe(true) const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -851,7 +867,13 @@ describe('web e2e: resident question composer round trip', () => { await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() - await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + const answer = composer.getByRole('textbox') + await answer.fill('Keep the migration steps explicit.') + await answer.press('Enter') + await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor() + expect(await answer.evaluate(node => document.activeElement === node)).toBe(true) + await page.keyboard.type('Release owner') + expect(await answer.inputValue()).toBe('Release owner') await composer.getByRole('button', { name: 'Submit', exact: true }).click() expect(await outcome).toEqual({ value: { @@ -859,7 +881,7 @@ describe('web e2e: resident question composer round trip', () => { id: 'release', selected: ['Explicit install'], custom: 'Keep the migration steps explicit.', - }], + }, { id: 'followup', selected: [], custom: 'Release owner' }], }, }) await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ab03603969..eea4ee840d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,7 +92,7 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 6f6d7288e7..97305dfeea 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -64,6 +64,7 @@ type HoverCardProps = { * Group replacement removes the previous card immediately, including a fading preview. * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and @@ -88,6 +89,7 @@ export function HoverCard(props: HoverCardProps) { const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) + const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') @@ -118,6 +120,7 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { + keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() setPinned(false) @@ -206,10 +209,6 @@ export function HoverCard(props: HoverCardProps) { return () => { document.removeEventListener('keydown', dismiss, true) } }, [open, openOnClick, cancelClose, close]) - useEffect(() => { - if (pinned && !openOnClick) close() - }, [close, openOnClick, pinned]) - // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -321,6 +320,7 @@ export function HoverCard(props: HoverCardProps) { const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return + keyboardFocus.current = false clearTimer() cancelClose() if (openOnClick && !disabled && !pinned) { @@ -374,9 +374,7 @@ export function HoverCard(props: HoverCardProps) { setPhase('open') }} onPointerLeave={() => { - if (!pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (!pinned && !(anchored && keyboardFocus.current)) armClose() }} > {callout @@ -397,11 +395,33 @@ export function HoverCard(props: HoverCardProps) { ref={rootRef} className={clsx(css.root, anchored && css.inline)} onFocus={anchored ? (event) => { - if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } + if (!disabled && event.target.matches(':focus-visible')) { + keyboardFocus.current = true + cancelClose() + setPhase('open') + } } : undefined} onBlur={anchored ? (event) => { + keyboardFocus.current = false if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} + onKeyDown={callout && open ? (event) => { + const scroll = scrollRef.current + if (scroll === null || !rootRef.current?.contains(event.target as Node)) return + let top: number + switch (event.key) { + case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break + case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break + case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break + case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break + case 'Home': top = 0; break + case 'End': top = scroll.scrollHeight; break + default: return + } + event.preventDefault() + event.stopPropagation() + scroll.scrollTop = top + } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return if (event.pointerType === 'mouse' && !window.matchMedia('(any-hover: hover)').matches) return @@ -418,9 +438,7 @@ export function HoverCard(props: HoverCardProps) { // Leaving a closed card schedules a no-op close; only arm while // open, matching Menu's shape. Keyboard focus remains an independent // open trigger after the pointer leaves. - if (open && !pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() }} onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index fa51eef33a..e1730702db 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -331,11 +331,10 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('drops a click pin when click opening is removed or the card is disabled', () => { + it('drops a click pin when the card is disabled', () => { const pinnedCard = (disabled = false) => ( details} content={
card body
} /> ) - const hoverCard = details} content={
card body
} /> const view = render(pinnedCard()) const anchor = screen.getByRole('button', { name: 'details' }) fireEvent.click(anchor) @@ -343,11 +342,6 @@ describe('HoverCard', () => { view.rerender(pinnedCard(true)) expect(screen.queryByText('card body')).toBeNull() - view.rerender(pinnedCard()) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(hoverCard) - expect(screen.queryByText('card body')).toBeNull() }) it('removes click-pin dismissal listeners on unmount', () => { @@ -885,3 +879,44 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) + +it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { + const { wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy: value' }) + act(() => { card.focus() }) + expect(document.activeElement).toBe(card) + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() +}) + +it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + act(() => { anchor.focus() }) + fireEvent.click(anchor) + fireEvent.click(anchor) + fireEvent.pointerEnter(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('body')).toBeTruthy() + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('body')).toBeNull() +}) + +it('scrolls an open callout with the focused anchor keyboard controls', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement + Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) + Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { + expect(fireEvent.keyDown(anchor, { key })).toBe(false) + expect(scroll.scrollTop).toBe(top) + } + fireEvent.keyDown(anchor, { key: 'Tab' }) + expect(screen.queryByText('body')).toBeNull() +}) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 8608006f15..b5573fba8f 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery: - en: 3509678f4d183834 - zh: 625542b79e434eb5 + en: c2307453aedca0d9 + zh: 683e57c6ea0b947e /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 83e50f9533..5e7738a74a 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -43,7 +43,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review ### Failure and recovery -An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. +An answer field autofocuses only when its answer channel is ready and no countdown exists. Custom-answer focus survives Enter navigation between option questions; navigation resets the question scroll position and closes its disclosures. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index f92fe5cf37..27d34e7059 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -43,7 +43,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 失败与恢复 -回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 +回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并关闭其说明浮层。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 89852a34a6..730be0fa6f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -144,7 +144,7 @@ .detail { flex-shrink: 0; - margin: 0 14px 8px; + margin: 0 24px 8px; } .footerActions { @@ -445,7 +445,7 @@ it pushes the inline input past the footer. */ flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; + border: 0.5px solid transparent; border-radius: var(--dsw-radius-md); transition: background-color 120ms ease, border-color 120ms ease; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0421a8f718..0e3aa666fa 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -245,6 +245,10 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const [questionTruncated, setQuestionTruncated] = useState(false) const [descriptionModes, setDescriptionModes] = useState | null>(null) + useLayoutEffect(() => { + if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 + }, [index]) + useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return @@ -549,7 +553,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
{ choose(option.label) }} onKeyDown={(event) => { @@ -651,6 +656,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {showDetails && ( { const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() - expect(secondScroll).not.toBe(firstScroll) + expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) expect(screen.queryByText(firstDescription)).toBeNull() expect(screen.getAllByText(secondDescription)).toHaveLength(1) @@ -1043,3 +1043,25 @@ describe('review card', () => { expect(screen.getByText(en['review.skipped'])).toBeTruthy() }) }) + +it('retains custom-answer focus across Enter navigation between option questions', () => { + const { carrier } = wait([ + { id: 'one', question: 'First?', options: [{ label: 'One', description: 'First description' }] }, + { id: 'two', question: 'Second?', options: [{ label: 'Two' }] }, + ]) + render() + const choice = screen.getByRole('radio', { name: 'One' }) + expect(choice.getAttribute('aria-describedby')).toBeTruthy() + expect(document.getElementById(choice.getAttribute('aria-describedby') as string)?.textContent).toBe('First description') + const first = screen.getByRole('textbox') + act(() => { first.focus() }) + fireEvent.change(first, { target: { value: 'first answer' } }) + fireEvent.keyDown(first, { key: 'Enter' }) + expect(screen.getByRole('heading', { name: 'Second?' })).toBeTruthy() + const second = screen.getByRole('textbox') + expect(second).toBe(first) + expect(document.activeElement).toBe(second) + fireEvent.change(second, { target: { value: 'second answer' } }) + expect((second as HTMLTextAreaElement).value).toBe('second answer') + expect(screen.getByRole('radio', { name: 'Two' }).getAttribute('aria-describedby')).toBeNull() +}) diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 99c6c0e4bb..4b6193da5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -13,3 +13,4 @@ - status - button "Skip" - button "Submit" [disabled] +Choice descriptions: [{"label":"Blue","description":"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards."},{"label":"Green","description":"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions."}] From f3bb36263def15e21e0092c6a108edd2ffbdd271 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 15:34:01 +0800 Subject: [PATCH 17/43] fix(client): complete hover-card coverage and steering golden --- packages/client/ui-primitives/tests/hover-card.client.spec.tsx | 2 ++ .../ui-user-questions/src/client/QuestionComposer.module.css | 1 + snapshots/web/steering/mid-steer.expected.md | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index e1730702db..a555826d23 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -913,6 +913,8 @@ it('scrolls an open callout with the focused anchor keyboard controls', () => { const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) + expect(scroll.scrollTop).toBe(0) for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { expect(fireEvent.keyDown(anchor, { key })).toBe(false) expect(scroll.scrollTop).toBe(top) diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 730be0fa6f..80841b8c3b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -409,6 +409,7 @@ height: 16px; border: 1px solid currentColor; border-radius: 50%; + corner-shape: round; } .descriptionPreview { diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 1527155f0b..267dbd18d5 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -16,9 +16,9 @@ - button "Copy" - region "Ready to continue?": - text: Checkpoint - - heading "Ready to continue?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Ready to continue?" [level=2] - radiogroup: - radio "Yes": 1 Yes - radio "No": 2 No From 2b6360c1d9f4d71e6d610e9d5ee88560127e3947 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 20 Oct 2026 21:42:39 +0800 Subject: [PATCH 18/43] refactor(ui-user-questions): let CSS wrap option descriptions --- apps/web/tests/question-composer.e2e.ts | 4 +- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 27 +++---- .../src/client/QuestionComposer.tsx | 75 +++++++------------ .../question-composer-styles.client.spec.ts | 12 +-- .../user-questions-composer.client.spec.tsx | 18 ++++- 8 files changed, 58 insertions(+), 86 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index db6ae2e128..c8bd4a8a33 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -596,7 +596,7 @@ describe('web e2e: resident question composer round trip', () => { if (label === null || description === null) return null const style = getComputedStyle(description) return { - sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1, + sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom, fontSize: style.fontSize, lineHeight: style.lineHeight, hasDetails: row.querySelector('[data-option-details]') !== null, @@ -605,7 +605,7 @@ describe('web e2e: resident question composer round trip', () => { })) expect(rows).toHaveLength(4) expect(rows).toEqual(Array.from({ length: 4 }, () => ({ - sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none', + sameLine: true, fontSize: '13px', lineHeight: '20px', hasDetails: false, maskImage: 'none', }))) await composer.getByRole('radio', { name: '中(medium)' }).click() await composer.getByRole('button', { name: 'Submit', exact: true }).click() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b5573fba8f..997ea58421 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 02c47a01ae6fd4f4 - zh: 7adb34361d02bc9d + en: e8d6e22798e572cd + zh: 26c9657013b014cd /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 5e7738a74a..c959c3f286 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. CSS wraps option descriptions beside or below their labels, using 13px text with a 20px line height in both positions. Overflow detection adds disclosure actions without changing text widths. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 27d34e7059..d4f4e8d928 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。CSS 将选项说明排在标签旁或下方,两种位置均使用 13px 字号和 20px 行高。溢出检测会添加全文按钮,但不改变文本宽度。较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 80841b8c3b..7bda79528f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -216,7 +216,6 @@ } .optionRow { - --description-action-space: 30px; position: relative; width: 100%; min-height: 40px; @@ -243,10 +242,6 @@ cursor: pointer; } -.optionWithDescription { - padding-right: calc(12px + var(--description-action-space)); -} - .optionSelected { background: var(--dsw-alias-interactive-bg-hover); } @@ -324,12 +319,17 @@ } .optionCopy { - min-width: 0; + display: flex; flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; } .optionLine { display: flex; + max-width: 100%; align-items: baseline; flex-wrap: wrap; gap: 2px 6px; @@ -353,6 +353,8 @@ .description { display: block; + width: max-content; + max-width: 100%; min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); @@ -364,14 +366,7 @@ } .descriptionTruncated { - mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); -} - -.descriptionInline { - color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; - font-weight: 400; + mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px)); } .descriptionAction { @@ -586,10 +581,6 @@ padding: 8px 6px; } - .optionWithDescription { - padding-right: calc(6px + var(--description-action-space)); - } - .descriptionAction { right: 6px; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0e3aa666fa..20fb3bdfb7 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,5 +1,5 @@ import { - useEffect, useId, useLayoutEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' @@ -242,68 +242,46 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 - const [questionTruncated, setQuestionTruncated] = useState(false) - const [descriptionModes, setDescriptionModes] = useState | null>(null) + const [overflow, setOverflow] = useState<{ + question: typeof question + title: boolean + descriptions: Set + } | null>(null) + const questionTruncated = overflow?.question === question && overflow.title - useLayoutEffect(() => { + useEffect(() => { if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 }, [index]) - useLayoutEffect(() => { + useEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return const title = surface.querySelector('[data-question-title]') - const rows = [...surface.querySelectorAll('[data-option-row]')] + const descriptions = [...surface.querySelectorAll('[data-option-description]')] const measure = (): void => { - if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1) - const next = new Map() - for (const row of rows) { - const description = row.querySelector('[data-option-description]') - if (description === null) continue - const optionIndex = Number(row.dataset.optionIndex) - const copy = row.querySelector('[data-option-copy]') - const line = row.querySelector('[data-option-line]') - if (copy === null || line === null || copy.clientWidth === 0) { - next.set(optionIndex, 'truncated') - continue + const titleTruncated = title !== null && title.scrollHeight > title.clientHeight + 1 + const truncated = new Set() + for (const description of descriptions) { + if (description.scrollWidth > description.clientWidth + 1) { + truncated.add(Number(description.dataset.optionIndex)) } - const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space')) - // The action's reserved width must not make a fitting description appear truncated. - const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace) - const probe = line.cloneNode(true) as HTMLElement - probe.querySelector('[data-option-description]')?.remove() - const probeDescription = description.cloneNode(true) as HTMLElement - probeDescription.removeAttribute('id') - probeDescription.className = clsx(css.descriptionInline) - probe.append(probeDescription) - probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none' - document.body.append(probe) - const inlineWidth = probe.getBoundingClientRect().width - probeDescription.className = clsx(css.description) - probeDescription.style.width = 'max-content' - const blockWidth = probeDescription.getBoundingClientRect().width - probe.remove() - next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline' - : blockWidth <= fullWidth + 1 ? 'block' : 'truncated') } - setDescriptionModes(current => current !== null && current.size === next.size - && [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next) + setOverflow(current => current?.question === question && current.title === titleTruncated + && current.descriptions.size === truncated.size + && [...truncated].every(optionIndex => current.descriptions.has(optionIndex)) + ? current : { question, title: titleTruncated, descriptions: truncated }) } measure() const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) if (title !== null) observer?.observe(title) - for (const row of rows) { - observer?.observe(row) - const copy = row.querySelector('[data-option-copy]') - if (copy !== null) observer?.observe(copy) - } + for (const description of descriptions) observer?.observe(description) const fonts = 'fonts' in document ? document.fonts : null fonts?.addEventListener('loadingdone', measure) return () => { observer?.disconnect() fonts?.removeEventListener('loadingdone', measure) } - }, [index, minimized, question.question, question.options]) + }, [minimized, question]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -598,8 +576,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' - const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated' - const showDetails = hasDescription && descriptionMode === 'truncated' + const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- )} - content={} - /> - - )} +
{question.detail !== undefined && (
@@ -576,18 +542,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' - const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex) + const descriptionExpanded = expanded.has(optionIndex) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {showDetails && ( - - - - - )} - content={} - /> - + {hasDescription && ( + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index e8e42ac851..4995c365e4 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,8 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'question.details': '查看完整问题', - 'option.details': '查看“{option}”的选项说明', + 'text.expand': '全部', + 'text.collapse': '收起', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +65,8 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'question.details': 'Show full question', - 'option.details': 'Show details for “{option}”', + 'text.expand': 'More', + 'text.collapse': 'Show less', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index e65c8eb063..4774ce8e67 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -8,29 +8,95 @@ const stylesheet = readFileSync( 'utf8', ).replace(/\/\*[\s\S]*?\*\//g, ' ') -function declarations(selector: string): string[] { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) - if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) - return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function split(body: string | undefined): string[] { + return (body ?? '').split(';').map(part => part.trim()).filter(Boolean) } -describe('QuestionComposer compact long-copy layout', () => { - it('keeps the option description to one clipped line and fades only truncated text', () => { +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${escape(selector)}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return split(rule[1]) +} + +function keyframes(name: string): string[] { + const rule = new RegExp(`@keyframes ${escape(name)}\\s*\\{\\s*from, to\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${name}\` keyframes`) + return split(rule[1]) +} + +describe('QuestionComposer long-copy layout', () => { + it('reads question overflow against three lines and keeps two once it overflows', () => { + expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy') + expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ + 'max-height: calc(3lh + 2px)', + 'margin: 0 0 -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'scroll-timeline: --question-copy block', + 'animation-name: question-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --question-copy', + ])) + expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'mask-size: 100% 1lh, 100% 1lh', + 'mask-position: 0 0, 0 1lh', + ])) + expect(declarations('.questionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --question-copy', + ])) + }) + + it('reads description overflow against two lines and keeps one once it overflows', () => { + expect(declarations('.optionRow')).toContain('timeline-scope: --description-copy') expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', 'width: max-content', 'max-width: 100%', - 'min-width: 0', + 'max-height: calc(2lh + 2px)', + 'margin-bottom: -2px', + 'padding-bottom: 2px', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: clip', - 'white-space: nowrap', + 'scroll-timeline: --description-copy block', + 'animation-name: description-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --description-copy', ])) - expect(declarations('.descriptionTruncated')).toContain( - 'mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px))', - ) + expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'mask-size: 100% 1lh', + ])) + expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --description-copy', + ])) + }) + + it('shows a toggle only through the overflow timeline or an expansion', () => { + expect(declarations('.copyToggle')).toContain('visibility: hidden') + expect(keyframes('copy-toggle-shown')).toEqual(['visibility: visible']) + expect(declarations('.questionTitle[data-expanded] .titlePreview,\n.optionRow[data-expanded] .description')) + .toEqual(['max-height: none', 'animation: none']) + expect(declarations('.questionTitle[data-expanded] .copyToggle,\n.optionRow[data-expanded] .copyToggle')) + .toEqual(['visibility: visible', 'animation: none']) + }) + + it('names timelines through longhands that the bundled CSS keeps apart', () => { + // lightningcss merges `animation` with `animation-timeline` into a shorthand browsers reject. + const timed = [...stylesheet.matchAll(/\{([^{}]*animation-timeline[^{}]*)\}/g)].map(match => match[1] ?? '') + expect(timed).toHaveLength(4) + for (const body of timed) expect(split(body).some(part => part.startsWith('animation:'))).toBe(false) + }) + + it('shows all copy where scroll timelines are unsupported', () => { + const fallback = /@supports not \(animation-timeline: scroll\(\)\)\s*\{([\s\S]*?)\n\}/.exec(stylesheet)?.[1] ?? '' + expect(fallback).toMatch(/\.titlePreview,\s*\.description\s*\{\s*max-height: none;\s*animation: none;\s*\}/) + expect(fallback).toMatch(/\.copyToggle\s*\{\s*display: none;\s*\}/) }) it('keeps the question and choices in the card scrollport', () => { diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 808d371502..c1ab2c6f7f 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -300,73 +300,7 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) - it('opens the full truncated question and removes its action on the next fitting question', () => { - const question = 'First question with four rendered lines' - const scrollHeight = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-question-title') && this.textContent === question ? 88 : 66 - }) - const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(66) - try { - const { carrier } = wait([ - { id: 'first', question, options: [{ label: 'Continue' }] }, - { id: 'second', question: 'Second question?', options: [{ label: 'Continue' }] }, - ]) - render() - const details = screen.getByRole('button', { name: '查看完整问题' }) - expect(details.getAttribute('aria-describedby')).toBe(screen.getByRole('heading', { name: question }).id) - fireEvent.click(details) - expect(screen.getAllByText(question)).toHaveLength(2) - expect(screen.getByRole('radio', { name: 'Continue' }).getAttribute('aria-checked')).toBe('false') - fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) - expect(screen.queryByText(question)).toBeNull() - expect(screen.queryByRole('button', { name: '查看完整问题' })).toBeNull() - } finally { - cleanup() - scrollHeight.mockRestore() - clientHeight.mockRestore() - } - }) - - it('shows details only for overflow and removes them when the description fits after resizing', ({ onTestFinished }) => { - let availableWidth = 300 - const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(200) - const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => availableWidth) - const disconnect = vi.fn() - let resize = (): void => {} - vi.stubGlobal('ResizeObserver', class { - constructor(callback: () => void) { resize = callback } - observe(): void {} - disconnect = disconnect - }) - onTestFinished(() => { - cleanup() - scrollWidth.mockRestore() - clientWidth.mockRestore() - vi.unstubAllGlobals() - }) - const description = 'An explanation that fits in a wide row.' - const { carrier } = wait([{ - id: 'size', question: 'Which option?', options: [{ label: 'Option', description }], - }]) - const { unmount } = render() - const text = screen.getByText(description) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - availableWidth = 100 - act(() => { resize() }) - expect(screen.getByRole('button', { name: '查看“Option”的选项说明' })).toBeTruthy() - availableWidth = 300 - act(() => { resize() }) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - expect(screen.getByText(description)).toBe(text) - unmount() - expect(disconnect).toHaveBeenCalledOnce() - }) - - it('opens a full option description without choosing the option', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) + it('expands long copy in place without choosing an option', () => { const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' const { carrier } = wait([{ id: 'release', question: 'How should the release graph change?', @@ -379,29 +313,39 @@ describe('QuestionComposer', () => { }]) render() + // CSS shows each toggle only while its copy overflows; every candidate is rendered. + const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) + expect(rest).toHaveLength(0) + expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) - const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) - expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) - expect(screen.getAllByText(description)).toHaveLength(1) - const describedBy = details.getAttribute('aria-describedby') - expect(describedBy).not.toBeNull() - expect(document.getElementById(describedBy as string)?.textContent).toBe(description) - fireEvent.click(details) + const text = screen.getByText(description) + expect(option.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(descriptionToggle as HTMLElement) + expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle?.textContent).toBe('收起') + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) + expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') - expect(details.getAttribute('aria-describedby')).toBe(describedBy) - expect(screen.getAllByText(description)).toHaveLength(2) - fireEvent.pointerDown(document.body) - expect(screen.getAllByText(description)).toHaveLength(1) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(questionToggle as HTMLElement) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) + fireEvent.click(questionToggle as HTMLElement) + fireEvent.click(descriptionToggle as HTMLElement) + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) + expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) }) - it('resets scrolling and an open option description when the question changes', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) - const firstDescription = 'First question detail that was pinned open.' - const secondDescription = 'Second question detail starts closed.' + it('resets scrolling and expanded copy when the question changes', () => { + const firstDescription = 'First question detail that was expanded.' + const secondDescription = 'Second question detail starts collapsed.' const { carrier } = wait([ { id: 'first', question: 'First question?', @@ -417,13 +361,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - fireEvent.click(firstDetails) - expect(screen.getAllByText(firstDescription)).toHaveLength(2) - const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(firstDescribedIds.length).toBeGreaterThan(0) - expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -431,14 +370,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - expect(screen.getAllByText(secondDescription)).toHaveLength(1) - const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(describedIds.length).toBeGreaterThan(0) - expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -1002,11 +938,7 @@ function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) { } describe('review card', () => { - it('walks a settled call record with every answer surface frozen', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) + it('walks a settled call record with every answer surface frozen', () => { const { carrier } = reviewCard() render() @@ -1015,7 +947,8 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) - expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) + // Long copy stays readable on a frozen card. + expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 82b43da3dc63a501bd6163982208656bb9c0b244 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:14:46 +0800 Subject: [PATCH 22/43] revert(ui-primitives): drop HoverCard callout and click pinning The question composer expands long copy in place, so no caller uses the callout variant, openOnClick pinning, describeAnchor, or the preview-to-anchor pointer handling this pull request added. HoverCard, its stylesheet, tests, and README return to their merge-base state. --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/HoverCard.module.css | 50 ---- .../client/ui-primitives/src/HoverCard.tsx | 236 ++++-------------- .../tests/hover-card.client.spec.tsx | 234 +---------------- 6 files changed, 54 insertions(+), 478 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc9c17df52..ab799d699a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 3c1f0a4c28aa7240 - zh: 8dbc3c8615c58260 + en: 35403eb2a55cc77d + zh: 8f4466f7e15ae473 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 3a31ad8de6dd0af8 zh: 6b22881a929c33df diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b1fafe0b64..9e054e13cc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,12 +88,10 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Moving directly from the preview back to its anchor keeps it open. Its `callout` variant draws an anchor caret, keeps an 8px viewport margin, and scrolls content beyond the available space or 240px height cap. With `openOnClick`, clicking or touching the anchor pins the card; pointer leave and blur keep a pinned card open. Another activation, Escape, Tab, or an outside pointerdown dismisses it. Focused callout anchors scroll the full text with arrow keys, PageUp/PageDown, Home, and End. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. - `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index bdd67dcce1..96b9db405a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,12 +88,10 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。指针从预览直接移回其锚点时,预览保持打开。其 `callout` 变体绘制指向锚点的小角,保留 8px 视口边距,并在内容超过可用空间或 240px 高度上限时内部滚动。启用 `openOnClick` 后,点击或触屏激活锚点会固定卡片;指针移出与失焦不会关闭已固定的卡片。再次激活、Escape、Tab 或外部 pointerdown 会关闭它。聚焦的 callout 锚点支持通过方向键、PageUp/PageDown、Home 和 End 滚动全文。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 -显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 - `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 0542763a8a..50c62fa828 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,53 +88,3 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } - -.callout { - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - isolation: isolate; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.calloutSurface { - position: relative; - z-index: 1; - padding: 4px; - overflow: hidden; - border: 0; - border-radius: var(--dsw-radius-xl); - background: var(--dsw-specific-bubble); - box-shadow: var(--dsw-elevation-panel); -} - -.calloutBody { - box-sizing: border-box; - max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); - overflow-y: auto; - overscroll-behavior: contain; - padding: 4px 8px; - border-radius: var(--dsw-radius-lg); - color: var(--dsw-alias-label-primary); -} - -.calloutCaret { - position: absolute; - z-index: 2; - left: var(--dsh-hover-card-anchor-x); - width: 10px; - height: 10px; - background: var(--dsw-specific-bubble); - transform: translateX(-50%) rotate(45deg); - pointer-events: none; -} - -.callout[data-placement='below'] .calloutCaret { - top: -4px; -} - -.callout[data-placement='above'] .calloutCaret { - bottom: -4px; -} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 601f5782ea..e30d671c36 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,12 +1,11 @@ -import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' -import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -14,101 +13,64 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 -const CALLOUT_MAX_HEIGHT = 240 -const CALLOUT_WIDTH = 300 -const CALLOUT_ARROW_EDGE_INSET = 16 -const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 -interface DescribedAnchorProps { - 'aria-describedby'?: string | undefined -} - -type HoverCardProps = { - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' | 'callout' - widthAnchorRef?: RefObject -} & ({ - anchor: ReactElement - openOnClick: true - describeAnchor?: boolean -} | { - anchor: ReactNode - openOnClick?: false | undefined - describeAnchor?: never -}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -}) - /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). With - * openOnClick, pass one focusable element that forwards ARIA props. + * Touch entry does not open a preview or intercept the anchor's action. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor; a preview with container-relative - * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. - * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, - * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. - * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. - * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible - * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard(props: HoverCardProps) { - const { - content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, - } = props - const openOnClick = props.openOnClick === true - const callout = variant === 'callout' - const anchored = inline || callout - const id = useId() +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, grouped = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, +}: { + anchor: ReactNode + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) - const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) - const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ - left: number - top: number - width?: number - maxHeight?: number - anchorX?: number - placement?: 'above' | 'below' - } | null>(null) + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) - const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -120,15 +82,12 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { - keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() - setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -181,7 +140,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !anchored)) return + if (!open || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -191,23 +150,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, anchored, cancelClose, close]) - - useEffect(() => { - if (!openOnClick || !open) return - const dismiss = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' && event.key !== 'Tab') return - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - } - clearTimer() - cancelClose() - close() - } - document.addEventListener('keydown', dismiss, true) - return () => { document.removeEventListener('keydown', dismiss, true) } - }, [open, openOnClick, cancelClose, close]) + }, [open, variant, inline, cancelClose, close]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -235,34 +178,19 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (anchored) { - const height = callout - ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) - : Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, - window.innerWidth - VIEWPORT_MARGIN * 2)) + if (inline) { + const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, - onTop ? above : below) - const anchorCenter = (r.left + r.right) / 2 - const idealLeft = callout ? anchorCenter - width / 2 : r.left - const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, - window.innerWidth - width - VIEWPORT_MARGIN)) - const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) + const maxHeight = onTop ? above : below setPos({ - left, + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, - ...(callout - ? { - anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), - placement: onTop ? 'above' as const : 'below' as const, - } - : {}), }) return } @@ -270,8 +198,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -281,19 +209,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, anchored, callout]) + }, [open, variant, widthAnchorRef, positioned, inline]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || anchored) return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, anchored]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -310,43 +238,21 @@ export function HoverCard(props: HoverCardProps) { } const copyable = copyText !== undefined - const describedAnchor = props.openOnClick === true && describeAnchor - ? cloneElement(props.anchor, { - 'aria-describedby': open - ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') - : props.anchor.props['aria-describedby'], - }) - : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return - keyboardFocus.current = false clearTimer() cancelClose() - if (openOnClick && !disabled && !pinned) { - setPinned(true) - setPhase('open') - return - } close() } - const cardContent = copied ? : content const card = open && pos !== null && (
{ - cancelClose() - setPhase('open') - }} - onPointerLeave={(event) => { - // Portal-to-anchor movement stays within this card's interaction. - if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) { - cancelClose() - return - } - if (!pinned && !(anchored && keyboardFocus.current)) armClose() - }} > - {callout - ? ( - <> -
) return ( { - if (!disabled && event.target.matches(':focus-visible')) { - keyboardFocus.current = true - cancelClose() - setPhase('open') - } + className={clsx(css.root, inline && css.inline)} + onFocus={inline ? (event) => { + if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={anchored ? (event) => { - keyboardFocus.current = false - if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } - } : undefined} - onKeyDown={callout && open ? (event) => { - const scroll = scrollRef.current - if (scroll === null || !rootRef.current?.contains(event.target as Node)) return - let top: number - switch (event.key) { - case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break - case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break - case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break - case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break - case 'Home': top = 0; break - case 'End': top = scroll.scrollHeight; break - default: return - } - event.preventDefault() - event.stopPropagation() - scroll.scrollTop = top + onBlur={inline ? (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -441,14 +304,13 @@ export function HoverCard(props: HoverCardProps) { onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. Keyboard focus remains an independent - // open trigger after the pointer leaves. - if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() + // open, matching Menu's shape. + if (open) armClose() }} - onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} + onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {describedAnchor} + {anchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index cfdf11bc10..65fbad0a64 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' | 'callout' + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,27 +165,6 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement - expect(card.style.width).toBe('300px') - expect(card.style.left).toBe('8px') - expect(card.style.top).toBe('82px') - expect(card.dataset.placement).toBe('below') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') - - Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) - wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) - fireEvent(window, new Event('resize')) - expect(card.style.top).toBe('452px') - expect(card.dataset.placement).toBe('above') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - }) - it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -246,127 +225,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('pins on click for touch access and keeps the card through pointer leave and blur', () => { - render( - <> -

Existing description

- details} - content={
card body
} - /> - , - ) - const anchor = screen.getByRole('button', { name: 'details' }) - const wrapper = anchor.parentElement as HTMLElement - const touch = new Event('pointerover', { bubbles: true }) - Object.defineProperty(touch, 'pointerType', { value: 'touch' }) - fireEvent(wrapper, touch) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - const card = screen.getByText('card body').parentElement as HTMLElement - expect(card.getAttribute('data-pinned')).toBe('true') - expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.getByText('card body')).toBeTruthy() - - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(card) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') - }) - - it('preserves a caller-owned accessible description while click-pinned', () => { - render(<> -

Stable description

- details} - content={
card body
} - /> - ) - const anchor = screen.getByRole('button', { name: 'details' }) - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - }) - - it('dismisses a click-open card on another click, Escape, or Tab', () => { - const ownerKey = vi.fn() - render(
- details} content={
card body
} /> -
) - const anchor = screen.getByRole('button', { name: 'details' }) - - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.click(anchor) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - fireEvent.keyDown(anchor, { key: 'a' }) - expect(screen.getByText('card body')).toBeTruthy() - ownerKey.mockClear() - expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) - expect(ownerKey).not.toHaveBeenCalled() - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('drops a click pin when the card is disabled', () => { - const pinnedCard = (disabled = false) => ( - details} content={
card body
} /> - ) - const view = render(pinnedCard()) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(pinnedCard(true)) - expect(screen.queryByText('card body')).toBeNull() - - }) - - it('removes click-pin dismissal listeners on unmount', () => { - const view = render( - details} content={
card body
} />, - ) - const registrations = vi.spyOn(document, 'addEventListener') - const removals = vi.spyOn(document, 'removeEventListener') - fireEvent.click(screen.getByRole('button', { name: 'details' })) - expect(screen.getByText('card body')).toBeTruthy() - - const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') - expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) - view.unmount() - - for (const [type, listener, options] of listeners) { - const removed = removals.mock.calls.find(([removedType, removedListener]) => - removedType === type && removedListener === listener) - expect(removed).toBeDefined() - expect(removed?.[2]).toBe(options) - } - expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) - fireEvent.pointerDown(document.body) - }) - it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -443,37 +301,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('keeps the card open while its own pointer is inside and closes after leaving it', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) - fireEvent.pointerEnter(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('keeps a preview open when the pointer returns directly from its portal to the anchor', () => { - const { anchor, wrapper } = mount({ variant: 'preview' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').parentElement as HTMLElement - fireEvent.pointerEnter(card) - fireEvent(card, new MouseEvent('pointerout', { bubbles: true, relatedTarget: anchor })) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS + 100) }) - expect(card.hasAttribute('data-closing')).toBe(false) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - act(() => { vi.advanceTimersByTime(100) }) - expect(screen.queryByText('card body')).toBeNull() - }) - it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -829,22 +656,6 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) -it('keeps an inline preview open when the pointer leaves its focused anchor', () => { - render(details} content={thumbnail} />) - const anchor = screen.getByRole('button', { name: 'details' }) - const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) - fireEvent.focus(anchor) - expect(screen.getByText('thumbnail')).toBeTruthy() - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('thumbnail')).toBeTruthy() - activeElement.mockRestore() - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.queryByText('thumbnail')).toBeNull() - matches.mockRestore() -}) - it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
@@ -895,46 +706,3 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) - -it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { - const { wrapper } = mount({ copyText: 'value' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByRole('button', { name: 'Copy: value' }) - act(() => { card.focus() }) - expect(document.activeElement).toBe(card) - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() -}) - -it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - act(() => { anchor.focus() }) - fireEvent.click(anchor) - fireEvent.click(anchor) - fireEvent.pointerEnter(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getByText('body')).toBeTruthy() - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('body')).toBeNull() -}) - -it('scrolls an open callout with the focused anchor keyboard controls', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement - Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) - Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) - expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) - expect(scroll.scrollTop).toBe(0) - for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { - expect(fireEvent.keyDown(anchor, { key })).toBe(false) - expect(scroll.scrollTop).toBe(top) - } - fireEvent.keyDown(anchor, { key: 'Tab' }) - expect(screen.queryByText('body')).toBeNull() -}) From 012bfe03e2b34fee592785ffae2c78ae14f1f71a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:47:02 +0800 Subject: [PATCH 23/43] style(ui-user-questions): expand long copy with chevron icon buttons The expand and collapse controls reuse the composer's 24px icon button with the existing downward and upward chevron icons instead of text. Each carries a localized accessible name, which a portaled Tooltip shows on hover. The fade at the end of clipped copy narrows to the 24px button. --- apps/web/tests/question-composer.e2e.ts | 39 ++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 41 +++++----------- .../src/client/QuestionComposer.tsx | 49 +++++++++++-------- .../ui-user-questions/src/client/locales.ts | 12 +++-- .../user-questions-composer.client.spec.tsx | 46 +++++++++-------- 8 files changed, 99 insertions(+), 96 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index c96d102bdd..f6a0acfa63 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: /** * Read how the composer draws one long-copy element: the lines it shows, - * whether CSS clipped it, whether its text overflows the line budget, and the - * label of its toggle while that toggle is visible. + * whether CSS clipped it, whether its text overflows the line budget, and what + * its toggle does while that toggle is visible. * @param element - a question heading or an option description. * @returns the drawn state of the copy. */ -function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: string | undefined } { +function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } { const style = getComputedStyle(element) const toggle = element.tagName === 'H2' ? element.nextElementSibling @@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over clipped: style.maskImage !== 'none', overflows: element.scrollHeight > element.clientHeight + 0.5, toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' - ? toggle.textContent ?? undefined + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' : undefined, } } @@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => { const copy = await description.evaluate(readCopy) expect(copy.lines).toBeLessThanOrEqual(2) expect(copy.clipped).toBe(copy.overflows) - expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) } scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => { const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueText = composer.locator('[data-option-description]').first() const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await blueToggle.click() expect(await blue.getAttribute('aria-checked')).toBe('false') expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') - expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) await blueToggle.click() // A collapsed copy's scroll timeline reports the overflow from the next frame. - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await page.setViewportSize(originalViewport) // Multi-line custom answer: the field is a textarea whose hidden mirror @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => { // Past its three-line budget the question keeps two lines; past its // two-line budget a description keeps one. Copy within budget shows in full. await expect.poll(() => heading.evaluate(readCopy)) - .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Explicit install').evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Keep the default edge').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) for (const copy of await composer.locator('[data-option-description]').all()) { const drawn = await copy.evaluate(readCopy) expect(drawn.clipped).toBe(drawn.overflows) - expect(drawn.toggle).toBe(drawn.clipped ? 'More' : undefined) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) } if (viewport.width === 512) { @@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') const expandedQuestion = await heading.evaluate(readCopy) - expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true) expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true }) await questionToggle.click() - await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) - expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedDescription.lines).toBeGreaterThan(2) expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true) expect(await explicitText.evaluate((text) => { @@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => { child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) })).toBe(true) - // The toggle's ink stays readable on the card in both themes. + // The toggle's icon keeps non-text contrast on the card in both themes. const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => { const foreground = luminance(colors.text) const background = luminance(colors.surface) expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) - .toBeGreaterThanOrEqual(4.5) + .toBeGreaterThanOrEqual(3) palette.push(colors.surface) } expect(palette[0]).not.toBe(palette[1]) @@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => { } await descriptionToggle.click() await expect.poll(() => explicitText.evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await composer.getByRole('checkbox', { name: 'Explicit install' }).click() const answer = composer.getByRole('textbox') diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 68f0c3442a..974ab9c64e 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: fda2aac4a93d6550 - zh: aa5ba8322b566f37 + en: 2173107a23e9f92a + zh: d3a6d17e50dd4b51 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index f31e20910f..9f2fe46110 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index c487cdc47b..2ac78bf4c1 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index a2de7c3ff6..fa75de76e4 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -109,7 +109,7 @@ /* Long copy keeps one line fewer than its budget once it overflows: a question shows in full up to three lines, an option description up to two; longer copy drops its last line, fades at the end of the line before, and - shows its toggle there. Each box keeps its full budget, plus a 2px + shows its expand button there. Each box keeps its full budget, plus a 2px allowance for glyphs taller than the line, so the scroll timeline that reports the overflow never depends on the collapsed layout. A negative bottom margin, not a smaller box, gives the dropped line back to the row. */ @@ -121,7 +121,7 @@ } .questionTitle[data-expanded] { - padding-bottom: 22px; + padding-bottom: 24px; } .titlePreview { @@ -142,7 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh, 100% 1lh; mask-position: 0 0, 0 1lh; mask-repeat: no-repeat; @@ -151,8 +151,8 @@ .questionToggle { position: absolute; - right: -6px; - bottom: 0; + right: -4px; + bottom: -1px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --question-copy; @@ -400,7 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; } @@ -408,16 +408,16 @@ .descriptionToggle { position: absolute; - right: 6px; - bottom: 7px; + right: 8px; + bottom: 6px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --description-copy; } -/* Expanded copy keeps its toggle on a line of its own below the text. */ +/* Expanded copy keeps its collapse button on a line of its own below the text. */ .optionRow[data-expanded] .option { - padding-bottom: 30px; + padding-bottom: 32px; } .questionTitle[data-expanded] .titlePreview, @@ -426,18 +426,8 @@ animation: none; } +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ .copyToggle { - padding: 1px 6px; - border: 0; - border-radius: var(--dsw-radius-sm); - background: transparent; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 13px; - line-height: 20px; - font-weight: 400; - white-space: nowrap; - cursor: pointer; visibility: hidden; } @@ -453,13 +443,6 @@ animation: none; } -@media (hover: hover) { - .copyToggle:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); - } -} - /* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ @supports not (animation-timeline: scroll()) { .titlePreview, @@ -640,7 +623,7 @@ } .descriptionToggle { - right: 0; + right: 2px; } .footer { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index cfd8eeed0c..b8cdeae85b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + IconEditOutlineRegular, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { const next = new Set(current) @@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi > {question.question} - + + +
{question.detail !== undefined && (
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{hasDescription && ( - + + + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 4995c365e4..0e8d3e6bdf 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,10 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'text.expand': '全部', - 'text.collapse': '收起', + 'question.expand': '展开完整问题', + 'question.collapse': '收起问题', + 'option.expand': '展开“{option}”的完整说明', + 'option.collapse': '收起“{option}”的说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +67,10 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'text.expand': 'More', - 'text.collapse': 'Show less', + 'question.expand': 'Show the full question', + 'question.collapse': 'Show less of the question', + 'option.expand': 'Show the full description of “{option}”', + 'option.collapse': 'Show less of “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c1ab2c6f7f..7562833067 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -315,32 +315,34 @@ describe('QuestionComposer', () => { // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) - expect(rest).toHaveLength(0) - expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) + const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) + const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) expect(option.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(descriptionToggle as HTMLElement) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle?.textContent).toBe('收起') + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') - expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(questionToggle as HTMLElement) - expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(questionToggle) + expect(questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) - fireEvent.click(questionToggle as HTMLElement) - fireEvent.click(descriptionToggle as HTMLElement) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -361,8 +363,9 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) - expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) + for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -370,11 +373,12 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] - expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) + expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -948,7 +952,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 5d5bd8eb412c1a520fa8ce95a5139afdb643fde3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:03:13 +0800 Subject: [PATCH 24/43] test(web): re-check long question copy across chat and window widths A long question with long, width-sensitive, and short option descriptions is driven through the transcript width handle at its narrowest and widest positions, an opened and collapsed right sidebar, and a smaller and restored window. At each settled width every collapsed copy follows its own overflow, the expanded description stays expanded, and the actions stay reachable; collapsing the sidebar or restoring the window returns the wide state exactly. --- apps/web/tests/question-composer.e2e.ts | 153 ++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f6a0acfa63..a120479c59 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -861,6 +861,159 @@ describe('web e2e: resident question composer round trip', () => { } }, 60_000) + it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + // Sized for the chat-width drag range: the question takes three lines or + // fewer in the widest chat column and more in the narrowest, and the staged + // description takes two lines or fewer and more. + const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?` + const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.' + const explicit = Array.from({ length: 24 }, (_, index) => + `Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'rollout', + header: 'Rollout decision', + question, + options: [ + { label: 'Explicit install', description: explicit }, + { label: 'Stage the rollout', description: staged }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const frame = page.locator('[style*="grid-template-columns"]').first() + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await page.setViewportSize({ width: 1680, height: 1000 }) + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('radio', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + // Settled layout: no AppFrame column transition in flight, then every + // collapsed copy drawn as its own overflow decides, the expanded copy + // still expanded, and the actions reachable. + const settle = async () => { + await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false) + const read = async () => { + const card = await composer.locator('section').evaluate((section) => { + const scroll = section.querySelector('[data-question-scroll]') + const submit = [...section.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + const submitBox = submit?.getBoundingClientRect() + return { + width: Math.round(section.getBoundingClientRect().width), + actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + titleInScrollport: scroll?.contains(section.querySelector('h2')) === true, + } + }) + return { + card, + question: await heading.evaluate(readCopy), + explicit: await descriptionOf('Explicit install').evaluate(readCopy), + staged: await descriptionOf('Stage the rollout').evaluate(readCopy), + short: await descriptionOf('Keep the default edge').evaluate(readCopy), + } + } + await expect.poll(async () => { + const state = await read() + const decided = (copy: Awaited>['question'], budget: number) => + copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined) + && copy.lines <= (copy.clipped ? budget - 1 : budget) + return state.card.actionsReachable && state.card.titleInScrollport + && decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2) + && !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse' + }).toBe(true) + return await read() + } + // The right transcript handle widens the content column by twice its + // outward travel. The composer seat covers the handle's lower part, so + // the gesture grabs it beside the transcript. + const dragChatWidth = async (travel: number) => { + const box = await page.locator('[data-width-handle="right"]').boundingBox() + if (box === null) throw new Error('The chat width handle is not rendered') + const x = box.x + box.width / 2 + const y = box.y + 120 + await page.mouse.move(x, y) + await page.mouse.down() + try { + await page.mouse.move(x + travel, y, { steps: 8 }) + } finally { + await page.mouse.up() + } + } + + // Expanded copy is the user's choice and survives every width change below. + const explicitToggle = await toggleFor(descriptionOf('Explicit install')) + await explicitToggle.click() + expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true') + expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false') + + await dragChatWidth(-400) + const narrow = await settle() + expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await dragChatWidth(600) + const wide = await settle() + expect(wide.card.width).toBeGreaterThan(narrow.card.width) + expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined }) + expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + + // The right sidebar narrows the conversation column, and collapsing it restores the wide state. + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + const withSidebar = await settle() + expect(withSidebar.card.width).toBeLessThan(wide.card.width) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(await settle()).toEqual(wide) + + // A smaller window clamps the dragged width; restoring the window restores it. + await page.setViewportSize({ width: 900, height: 700 }) + const smallWindow = await settle() + expect(smallWindow.card.width).toBeLessThan(wide.card.width) + expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + expect(await settle()).toEqual(wide) + + await explicitToggle.click() + await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + // The dragged width is a persisted preference; dropping it and resizing republishes the default. + const cleanup = await Promise.allSettled([ + outcome, + page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') }) + .then(() => page.setViewportSize(original)), + ]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 90_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { From f8a094e4656594d9ec99feb688e5559268e23df2 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:48:30 +0800 Subject: [PATCH 25/43] fix(ui-user-questions): label every copy toggle Expand or Collapse Option names made the toggle tooltips as long and unpredictable as the option labels themselves. Every question and description toggle now uses the same Expand and Collapse copy, and the question toggle, like the description toggles, is described by the text it controls. --- apps/web/tests/question-composer.e2e.ts | 9 ++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 5 ++-- .../ui-user-questions/src/client/locales.ts | 12 +++------ .../user-questions-composer.client.spec.tsx | 27 +++++++++---------- 7 files changed, 29 insertions(+), 32 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index a120479c59..0861e3a8ba 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -758,10 +758,11 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') const expandedQuestion = await heading.evaluate(readCopy) expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) @@ -784,7 +785,7 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) - expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 974ab9c64e..09db781665 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 2173107a23e9f92a - zh: d3a6d17e50dd4b51 + en: 244a344475118aa3 + zh: 8ca447340de5d341 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9f2fe46110..d730c104df 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2ac78bf4c1..dba1010bec 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b8cdeae85b..5c00ccbf53 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -250,7 +250,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) - const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { @@ -533,6 +533,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={questionToggleLabel} aria-expanded={expanded.has('question')} aria-controls={`question-${pending.key}-${String(index)}`} + aria-describedby={`question-${pending.key}-${String(index)}`} onClick={() => { toggleCopy('question') }} > {expanded.has('question') ? : } @@ -548,7 +549,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) - const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{ // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) - const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) @@ -328,7 +329,7 @@ describe('QuestionComposer', () => { fireEvent.click(descriptionToggle) expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') @@ -336,13 +337,13 @@ describe('QuestionComposer', () => { fireEvent.click(questionToggle) expect(questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) fireEvent.click(questionToggle) fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -363,9 +364,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) - expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -373,12 +373,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() - expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) - expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -952,7 +951,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From fb290b388d92a73cb9ee189c8c44242bf8ec10fa Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 12:02:02 +0800 Subject: [PATCH 26/43] fix(ui-user-questions): clip dropped lines from hit testing A collapsed question or description keeps its full line budget and only masked its dropped line, so that transparent line still took clicks: it covered the first line of the question detail and the top edge of the custom answer row. clip-path now removes the line from hit testing as well as from view, and Web e2e hit tests pin both targets. Expansions also record the question they belong to and apply only while that question is shown, so a position change from another surface of the same Session no longer carries them to a different question. `.title` returns to its base margin; `.questionTitle` owns the flex sizing. --- apps/web/tests/question-composer.e2e.ts | 16 +++++++++++ .../src/client/QuestionComposer.module.css | 9 ++++--- .../src/client/QuestionComposer.tsx | 27 +++++++++++-------- .../question-composer-styles.client.spec.ts | 6 +++-- .../user-questions-composer.client.spec.tsx | 18 +++++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 0861e3a8ba..1e06d0cbb6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -433,6 +433,15 @@ describe('web e2e: resident question composer round trip', () => { const blueText = composer.locator('[data-option-description]').first() const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + // A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it. + expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true }) + expect(await composer.locator('textarea').evaluate((field) => { + const row = field.closest('[class*="customRow"]') + if (!(row instanceof HTMLElement)) return false + const box = row.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2) + return hit !== null && row.contains(hit) + })).toBe(true) await blueToggle.click() expect(await blue.getAttribute('aria-checked')).toBe('false') expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') @@ -754,6 +763,13 @@ describe('web e2e: resident question composer round trip', () => { expect(scrollStates).toContain(false) expect(scrollStates).toContain(true) + // A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail. + expect(await composer.getByText(detail, { exact: true }).evaluate((text) => { + const box = text.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + 8, box.top + 3) + return hit !== null && text.contains(hit) + })).toBe(true) + // The question expands in place; its choices move down inside the // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index fa75de76e4..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -99,8 +99,7 @@ } .title { - flex-shrink: 0; - margin: 5px 24px 0; + margin: 0; font-size: 16px; line-height: 22px; font-weight: 500; @@ -112,7 +111,8 @@ shows its expand button there. Each box keeps its full budget, plus a 2px allowance for glyphs taller than the line, so the scroll timeline that reports the overflow never depends on the collapsed layout. A negative - bottom margin, not a smaller box, gives the dropped line back to the row. */ + bottom margin, not a smaller box, gives the dropped line back to the row, + and clip-path removes that line from hit testing as well as from view. */ .questionTitle { position: relative; flex-shrink: 0; @@ -142,6 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh, 100% 1lh; mask-position: 0 0, 0 1lh; @@ -160,7 +161,6 @@ .cardMinimized .title { display: -webkit-box; - margin: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -400,6 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 5c00ccbf53..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -27,10 +27,15 @@ const REMOVED_CARD: QuestionCardSnapshot = { state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true, } -/** Long copy the user expanded: the question itself or an option description by index. */ -type ExpandedCopy = ReadonlySet<'question' | number> +/** Long copy the user expanded on one question: the question itself or an option description by index. */ +interface ExpandedCopy { + /** Index of the question the expansions belong to. */ + readonly index: number + /** Expanded copy of that question: `'question'` or an option index. */ + readonly copies: ReadonlySet<'question' | number> +} -const NOTHING_EXPANDED: ExpandedCopy = new Set() +const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set() /** * Split the conventional recommendation suffix without changing the answer value. @@ -247,24 +252,24 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 - // CSS decides which copy is clipped; only the user's expansions are state. - const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + // CSS decides which copy is clipped; only the user's expansions are state, + // and they apply only while their question is the one shown. + const [expandedCopy, setExpandedCopy] = useState({ index, copies: NOTHING_EXPANDED }) + const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(copy)) next.add(copy) - return next - }) + const copies = new Set(expanded) + if (!copies.delete(copy)) copies.add(copy) + setExpandedCopy({ index, copies }) } const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { if (nextIndex !== index) { // Another question starts at its top with its long copy collapsed. if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 - setExpanded(NOTHING_EXPANDED) + setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED }) } actions.replace(pending.key, { index: nextIndex, diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 4774ce8e67..7778302296 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -28,7 +28,7 @@ function keyframes(name: string): string[] { describe('QuestionComposer long-copy layout', () => { it('reads question overflow against three lines and keeps two once it overflows', () => { - expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy') + expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy'])) expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ 'max-height: calc(3lh + 2px)', 'margin: 0 0 -2px', @@ -41,6 +41,7 @@ describe('QuestionComposer long-copy layout', () => { ])) expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', 'mask-size: 100% 1lh, 100% 1lh', 'mask-position: 0 0, 0 1lh', ])) @@ -69,6 +70,7 @@ describe('QuestionComposer long-copy layout', () => { ])) expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', 'mask-size: 100% 1lh', ])) expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ @@ -104,6 +106,6 @@ describe('QuestionComposer long-copy layout', () => { 'min-height: 0', 'overflow-y: auto', ])) - expect(declarations('.title')).toContain('flex-shrink: 0') + expect(declarations('.questionTitle')).toContain('flex-shrink: 0') }) }) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 86650560fb..dfa9541320 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -380,6 +380,24 @@ describe('QuestionComposer', () => { expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) + it('keeps expansions with their question when the shared position moves elsewhere', () => { + const { carrier } = wait([ + { id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] }, + ]) + render() + fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + // Another surface of the same Session moves the shared question position. + const blank = { selected: [], custom: '', skipped: false } + act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) }) + + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(document.querySelector('[data-expanded]')).toBeNull() + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', From d12498798aaf223f0e6552be6324c870ea3afe44 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:26:54 +0800 Subject: [PATCH 27/43] fix(conversation): add missing Japanese quote removal label The Japanese dictionary landed without the quote removal copy its English and Chinese dictionaries already carry, so the conversation dictionaries do not typecheck against the current master. --- packages/client/ui-conversation/src/client/locales.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', From cf620a98df920b747f858ff3e785f4f16a0cba94 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:35:29 +0800 Subject: [PATCH 28/43] fix(message-quotes): register the Japanese dictionary The quote plugin's dictionaries predate the Japanese locale merge, so the locale dictionary parity gate rejects its object-form dictionary without Japanese copy. --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..fab1fbc782 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja = { + 'quote.add': 'テキストを引用', +} satisfies Record From c47d3285b5f7f2394438502dba4606e90ac42d6a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:50:06 +0800 Subject: [PATCH 29/43] test(snapshots): record the current present tool description The background-wait-steer pinned tool schemas still carry the pre-reword present description, so the snapshot header no longer matches the shipped tool. --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From 957379e8a908b7fd2e842406f16b2980ebafcdfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:18:51 +0800 Subject: [PATCH 30/43] test(session-log-export): adapt merged input actions Add the master-introduced insertQuote input action to the isolated Header action fixture so the merged client aggregate typechecks. --- .../session-log-export/tests/header-action.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 86a35d5e04..dc085293d5 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -26,7 +26,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useResource: unused, useWorkspaces: unused, - inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, + inputActions: { insertQuote: () => false, captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } From 0ab1fcb6747af599f31d4de171364761633e00e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:18:56 +0800 Subject: [PATCH 31/43] fix(session-log-export): clarify Session Markdown copy label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Header menu item to Copy Session as Markdown (复制会话为 Markdown) so it states that the whole selected Session is copied. Update Header selectors, browser coverage, controller JSDoc, and the bilingual README; success toast wording stays unchanged. --- apps/web/tests/navigation-panes.e2e.ts | 4 ++-- .../session-log-export/README.i18n.yaml | 14 +++++++------- .../session-query/session-log-export/README.md | 8 ++++---- .../session-log-export/README.zh.md | 8 ++++---- .../src/client/copy-controller.ts | 2 +- .../session-log-export/src/client/locales.ts | 4 ++-- .../tests/header-action.client.spec.tsx | 17 +++++++++++------ 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bce1dd19a4..ae8726c23f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -448,7 +448,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) const copyButton = page.getByRole('button', { name: 'More actions' }) await copyButton.click() - await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click() const response = await markdownResponse expect(response.status()).toBe(200) expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') @@ -481,7 +481,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(normalized.endsWith('\n\n')).toBe(false) await copyButton.click() - expect(await page.getByRole('menuitem', { name: 'Copy as Markdown' }).isDisabled()).toBe(false) + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false) await page.keyboard.press('Escape') }, 60_000) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 5fe8787069..b4cbc02951 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -9,17 +9,17 @@ en: eea87f5da57ff494 zh: db388203522b840f /deepseek-ai-dsh-session-log-export/summary: - en: 9268b3aaeaf82673 - zh: 99cc5b0de0b5a8bd + en: ee7d348dd7fe684f + zh: 6cd6d5d644176355 /deepseek-ai-dsh-session-log-export/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-session-log-export/use-this-package: - en: f37f81af3601784d - zh: 89b47be0660c8b8b -/deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: 59ba15abb59b6bd0 - zh: 1ea2b05fc05b9e61 + en: bb12bdeafa8571b5 + zh: e7df75c03478f7ed +/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown: + en: 55c163a4ae5b8d5d + zh: c1dac154ed1fa56d /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 4011f686afd24f09 zh: 2710b9995c80ba1f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 31416a2731..58cf4006df 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. +`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy Session as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy Session as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. -### Copy as Markdown +### Copy Session as Markdown -`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. +`Copy Session as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 67b94237e2..01df07877f 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 +`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制会话为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制会话为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 -### 复制为 Markdown +### 复制会话为 Markdown -`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 +`复制会话为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts index 50eeb0d1cb..7f4baa2a04 100644 --- a/packages/session-query/session-log-export/src/client/copy-controller.ts +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -1,5 +1,5 @@ /** - * Browser controller for the Session Header's "Copy as Markdown" action. One + * Browser controller for the Session Header's "Copy Session as Markdown" action. One * plugin instance owns one pending copy: the clipboard is a browser-wide * destination, so a second request joins the pending operation instead of * racing it. diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 36dbf042fe..b3bb8ba6e2 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -4,7 +4,7 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { 'header.more': '更多操作', - 'menu.copyMarkdown': '复制为 Markdown', + 'menu.copyMarkdown': '复制会话为 Markdown', 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', 'toast.copied': 'Session 已复制为 Markdown', @@ -22,7 +22,7 @@ export const zh = { /** English Session export strings. */ export const en: Record = { 'header.more': 'More actions', - 'menu.copyMarkdown': 'Copy as Markdown', + 'menu.copyMarkdown': 'Copy Session as Markdown', 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', 'toast.copied': 'Session copied as Markdown.', diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index dc085293d5..462064fbad 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId const clipboard: SessionMarkdownClipboard = { @@ -69,6 +69,11 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo afterEach(cleanup) describe('Session export Header action', () => { + it('names whole-Session copying in English and Chinese', () => { + expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown') + expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown') + }) + it('opens Session feedback and closes the menu without starting a download', () => { const b = bench(true) fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) @@ -82,7 +87,7 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })).toBeTruthy() + expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy() expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() }) @@ -108,10 +113,10 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - 'Copy as Markdown', + 'Copy Session as Markdown', 'Download session log', ]) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })) await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) expect(b.request).not.toHaveBeenCalled() expect(b.view.queryByRole('menu')).toBeNull() @@ -128,7 +133,7 @@ describe('Session export Header action', () => { const button = b.view.getByRole('button', { name: 'More actions' }) await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) fireEvent.click(button) - const copyItem = b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) + const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) @@ -188,7 +193,7 @@ describe('Session export Header action', () => { fireEvent.click(button) const item = b.view.getByRole('menuitem', { name: 'Download session log' }) expect((item as HTMLButtonElement).disabled).toBe(true) - expect((b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) as HTMLButtonElement).disabled).toBe(false) + expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) release(new Response('zip')) await download From e4e1411a5cb77fcb59838ab94f7b01de6dfd5388 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:56 +0800 Subject: [PATCH 32/43] test(session-log-export): remove redundant label assertion Keep the rendered Header and browser scenarios as the copy-action coverage, and describe the backend converter as used by the Session Markdown copy Host route. --- packages/session-query/session-log-export/src/markdown.ts | 6 +++--- .../session-log-export/tests/header-action.client.spec.tsx | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index 84261bee39..4ebd1d6be4 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -1,7 +1,7 @@ /** - * Pure backend converter for one complete committed Session log. The "Copy as - * Markdown" Host route uses it, and the package entry exports it for callers - * that already hold the logical events. The raw log is authoritative: + * Pure backend converter for one complete committed Session log. The Session + * Markdown copy Host route uses it, and the package entry exports it for + * callers that already hold the logical events. The raw log is authoritative: * compaction surface replacements never remove earlier messages, and * fork-inherited history appears once. Reasoning, system and developer * messages, injected non-human context, request headers, and runtime records diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 462064fbad..a8e6321b1a 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' -import { en, zh } from '../src/client/locales.ts' +import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId const clipboard: SessionMarkdownClipboard = { @@ -69,11 +69,6 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo afterEach(cleanup) describe('Session export Header action', () => { - it('names whole-Session copying in English and Chinese', () => { - expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown') - expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown') - }) - it('opens Session feedback and closes the menu without starting a download', () => { const b = bench(true) fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) From af4360620e03cd4f6116a31177e2b91d94ca6772 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:41:05 +0800 Subject: [PATCH 33/43] test(snapshots): align background wait present schema The SDK background-wait-steer fixture was authored on a branch that predates master's present description change (1ec651f2098). Refresh only its expected present tool description; committed Session generations and product behavior stay unchanged. --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From c7278aa4d5483a4d41bcc6c606cd77436435b1a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:46:00 +0800 Subject: [PATCH 34/43] fix(message-quotes): complete Japanese locale registration Add the Japanese quote action dictionary and register it with the existing Chinese and English dictionaries. The package entered master alongside the Japanese locale lane without a Japanese entry. --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..fab1fbc782 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja = { + 'quote.add': 'テキストを引用', +} satisfies Record From 09899f3a0fd7de180cd05666b2d4c8b9d4dae173 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:39:05 +0800 Subject: [PATCH 35/43] =?UTF-8?q?feat(remote-access):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=B7=9F=E9=9A=8F=E8=AE=BF=E9=97=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=EF=BC=88whenAccess=EF=BC=89=EF=BC=8Cindex=20=E6=8C=89?= =?UTF-8?q?=20Peer=20=E6=B3=A8=E5=85=A5=E8=AE=BF=E9=97=AE=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remote-access 新增 whenAccess / whenResourceAccess,ui-session、ui-workspace 各自提供 whenAccess - schedule、plugin-manager、settings-general、settings-account、directory-picker-native 的入口包进 global whenAccess;消息反馈按钮按会话 useAccess 渲染 - webserver/index-inject 带 Peer,renderIndex / collectIndexInjections 需要 Peer,remote-access 注入 snapshotFor(peer.principal) - client-test-runtime 新增 StaticRemoteAccess、staticAccessPlugin 与 TestClientOptions.access - implemented Agent Note 2026-10-21-when-access 与升级指南 webserver-index-peer Refs #2940 --- ...peer-principal-and-remote-access.i18n.yaml | 8 +- ...-10-12-peer-principal-and-remote-access.md | 4 +- ...-12-peer-principal-and-remote-access.zh.md | 4 +- .../2026-10-21-when-access.i18n.yaml | 28 ++ .../architecture/2026-10-21-when-access.md | 88 +++++ .../architecture/2026-10-21-when-access.zh.md | 88 +++++ apps/desktop-host/src/index.ts | 2 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/typert.md | 7 +- docs/subsystems/typert.zh.md | 7 +- docs/subsystems/web-server.md | 29 +- docs/subsystems/web-server.zh.md | 29 +- .../webserver-index-peer/guide.i18n.yaml | 16 + .../v0.2.1-rc.2/webserver-index-peer/guide.md | 17 + .../webserver-index-peer/guide.zh.md | 17 + packages/api/remote-access/README.i18n.yaml | 8 +- packages/api/remote-access/README.md | 5 +- packages/api/remote-access/README.zh.md | 5 +- .../api/remote-access/src/client/index.ts | 9 +- .../api/remote-access/src/client/mount.ts | 39 +++ .../remote-access/src/client/when-allowed.ts | 85 +++++ packages/api/remote-access/src/index.ts | 13 +- .../tests/client-access.client.spec.ts | 225 +++++++++++++ .../tests/remote-access.host.spec.ts | 21 +- .../api/remote-access/tsconfig.client.json | 1 + .../web-app/tests/document-preview.spec.ts | 7 +- .../connection/tests/node-half.host.spec.ts | 7 +- .../shortcuts/tests/config.host.spec.ts | 7 +- .../ui-directory-picker-native/package.json | 1 + .../src/client/index.ts | 32 +- .../tests/client-flow.client.spec.tsx | 18 +- .../src/client/MessageFeedbackActions.tsx | 15 +- .../message-feedback-actions.client.spec.tsx | 20 +- .../client/ui-plugin-manager/package.json | 1 + .../ui-plugin-manager/src/client/index.ts | 144 ++++----- .../tests/browser-plugin.client.spec.tsx | 20 +- packages/client/ui-schedule/package.json | 1 + .../client/ui-schedule/src/client/index.ts | 305 +++++++++--------- .../tests/browser-plugin.client.spec.ts | 25 +- packages/client/ui-session/README.i18n.yaml | 4 +- packages/client/ui-session/README.md | 2 +- packages/client/ui-session/README.zh.md | 2 +- .../client/ui-session/src/client/index.ts | 20 +- .../tests/ui-session.client.spec.ts | 8 + .../client/ui-settings-account/package.json | 1 + .../ui-settings-account/src/client/index.ts | 74 +++-- .../tests/apply.client.spec.ts | 10 + .../tests/config.client.spec.ts | 9 +- .../tests/initialization.client.spec.ts | 8 + .../client/ui-settings-general/package.json | 1 + .../ui-settings-general/src/client/index.ts | 84 ++--- .../tests/apply.client.spec.ts | 2 +- .../tests/shell.client.spec.ts | 8 +- .../tests/apply.client.spec.ts | 9 +- .../tests/config.host.spec.ts | 9 +- .../client/ui-theme/tests/config.host.spec.ts | 7 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/navigation.ts | 20 ++ .../ui-workspace/tests/apply.client.spec.ts | 10 +- .../tests/bootstrap-route.host.spec.ts | 5 +- .../inspector/tests/plugin.host.spec.ts | 7 +- .../webworker-runtime/src/worker-host.ts | 11 +- .../extensions/tool-cordis/src/api-catalog.ts | 22 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 3 +- packages/host/webserver/README.i18n.yaml | 12 +- packages/host/webserver/README.md | 6 +- packages/host/webserver/README.zh.md | 6 +- packages/host/webserver/package.json | 1 + packages/host/webserver/src/index.ts | 38 ++- .../host/webserver/tests/webserver.spec.ts | 16 +- packages/host/webserver/tsconfig.json | 3 + .../client-runtime/README.i18n.yaml | 12 +- .../test-support/client-runtime/README.md | 6 +- .../test-support/client-runtime/README.zh.md | 6 +- .../test-support/client-runtime/src/access.ts | 82 +++-- .../src/assembly/test-client.ts | 10 +- .../test-support/client-runtime/src/index.ts | 2 +- .../tests/access.client.spec.ts | 82 +++++ pnpm-lock.yaml | 18 ++ 87 files changed, 1497 insertions(+), 521 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.md create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md create mode 100644 packages/api/remote-access/src/client/when-allowed.ts create mode 100644 packages/test-support/client-runtime/tests/access.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..9378bc4f0b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -54,8 +54,8 @@ en: 7865e159528e2d30 zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: - en: 41d7ac1faf2448d7 - zh: 6d523895a9abae68 + en: a928a63e8c36177c + zh: c943c1fdc8c4bb45 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: en: 7be28cd7e19e5377 zh: 30b5aee957271d6c @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 048067eb02e6bf8a - zh: 12508b59f6b206b8 + en: 2279a8c153cc5b89 + zh: 63c0b6462722f0a3 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..c9894ae409 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | Behavior | Detail | |---|---| -| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts | | Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | | Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | | Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | @@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | | Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | -| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | | List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..ed124e8159 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | 行为 | 说明 | |---|---| -| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 | | 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | | 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | | 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | @@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | | 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | -| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | | 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml new file mode 100644 index 0000000000..6a7ccaae0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-when-access.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-when-access.md +/agent-note-whenaccess-registration-that-follows-access: + en: 2ef7cfef514c138a + zh: 064e3aecbfafcd9c +/agent-note-whenaccess-registration-that-follows-access/problem: + en: c12d401db33f7d77 + zh: 3bc2c8ad1a441bd7 +/agent-note-whenaccess-registration-that-follows-access/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess: + en: c168de20989cf9a4 + zh: bf3b0361beea083d +/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: + en: 0487c2e2f5e71bde + zh: 1750e83e7f9fa1cb +/agent-note-whenaccess-registration-that-follows-access/alternatives-considered: + en: 670e07d9da3ea599 + zh: 625e8e8bff57ff8c +/agent-note-whenaccess-registration-that-follows-access/consequences: + en: 5c7c7c49446fd140 + zh: f8fa106040fac4bc +/agent-note-whenaccess-registration-that-follows-access/related: + en: 89073ffbfcff0641 + zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md new file mode 100644 index 0000000000..66d8705c57 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -0,0 +1,88 @@ +# Agent Note: whenAccess registration that follows access + +Status: implemented + +English | [中文](2026-10-21-when-access.zh.md) + +## Problem + +Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering: + +| Where | Existing capability | Gap | +|---|---|---| +| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | +| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | + +#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change. + +## Decision + +### whenAccess + +The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook: + +| Interface | Provider | Matching Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`. + +| Rule | Content | +|---|---| +| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads | +| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration | +| Stream failure | A non-owner's answer turns false and the installed effects dispose | +| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask | +| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### Plugin migration + +| Plugin | Change | Action | +|---|---|---| +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` | +| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` | +| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | +| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | + +## Alternatives considered + +**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change. + +**Trim the plugin list per connection**: a plugin cannot reload at once when access changes. + +**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest. + +**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds. + +## Consequences + +| Consequence | Note | +|---|---| +| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged | +| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | +| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | +| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | + +## Related + +- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face. diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md new file mode 100644 index 0000000000..a4233dcdfe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 按访问状态注册的 whenAccess + +Status: implemented + +[English](2026-10-21-when-access.md) | 中文 + +## 问题 + +Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染: + +| 位置 | 现有能力 | 缺口 | +|---|---|---| +| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | +| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | + +#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。 + +## 决策 + +### whenAccess + +JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供: + +| 接口 | 提供者 | 对应 Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。 + +| 规则 | 内容 | +|---|---| +| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 | +| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 | +| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 | +| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 | +| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### 插件迁移 + +| 插件 | 改法 | action | +|---|---|---| +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` | +| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` | +| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | +| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | + +## 考虑过的替代方案 + +**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。 + +**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 + +**插件包清单声明访问性**:在包清单上定义访问控制概念。 + +**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。 + +## 后果 + +| 后果 | 说明 | +|---|---| +| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 | +| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | +| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | +| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | + +## 相关 + +- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..6ce80e1aa5 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -104,7 +104,7 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'platform-session', session }) }) const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) - if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) + if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } /** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 313b63aa2b..c2b9457b1c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1706,7 +1706,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 56f50c6fe4..f141ef600d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1708,7 +1708,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a405377a97..0e2c56c782 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e6010e3d32..1235fb8224 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -84,7 +84,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 29b5d4e3b7..d68d4a1a36 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ce33a70436..270e5509bd 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c85f7a9b08..425c0faadc 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d108ad5f67..d1d469d9e0 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml new file mode 100644 index 0000000000..1a41ab4629 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md +/: + en: 4182112137303167 + zh: 8451ce873cfca164 +/webserver-index-rendering-takes-a-peer: + en: 7986c2aaf1511f53 + zh: a6f78fc166e1ea19 +/webserver-index-rendering-takes-a-peer/change: + en: 07a11388305a0124 + zh: 662003b29c238d30 +/webserver-index-rendering-takes-a-peer/migration: + en: 7ef144b29ffb2f39 + zh: defb62ecc4ba6c2e diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md new file mode 100644 index 0000000000..42f8bdc773 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners." +--- +# WebServer index rendering takes a Peer + +English | [中文](guide.zh.md) + +## Change + +`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working. + +## Migration + +1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`. +2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`. +3. Run your TypeScript build and confirm no call to either method reports a missing argument. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md new file mode 100644 index 0000000000..de6d1c58da --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。" +--- +# WebServer 渲染 index 需要传入 Peer + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。 + +## 迁移 + +1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。 +2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。 +3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。 diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index df8ad59235..461af0d1f9 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,11 +15,11 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 9175835ea5c5bff1 - zh: 0e33b266fdac3d13 + en: 8a4be795c7aa2ef0 + zh: 02550b975e2e8d61 /deepseek-ai-dsh-api-remote-access/client-face: - en: 1db01235b6af84fa - zh: c0ced511cc1841a0 + en: d4cf8f646fdf2d39 + zh: 247f643e991aa806 /deepseek-ai-dsh-api-remote-access/model-experience: en: 8b07250cecacd3aa zh: d28ab93980bdcdad diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index 8d9df9e542..ab35a6b598 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. -The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts. | `details.reason` of `remote-access/denied` | Cause | |---|---| @@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: - the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind; - the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. -Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. +Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs. ## Model Experience diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 8d593f7132..514f885b33 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo 转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 -本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。 | `remote-access/denied` 的 `details.reason` | 原因 | |---|---| @@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: - Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事; - 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 -领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 +领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。 ## 模型体验 diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts index 396f56a16a..f3e5123b5b 100644 --- a/packages/api/remote-access/src/client/index.ts +++ b/packages/api/remote-access/src/client/index.ts @@ -2,8 +2,11 @@ * Client face: starts from the access snapshot the index injected, follows * the calling Peer's snapshot stream, provides the `remoteAccess` Client * service whose `source(kind, id, action)` answers for any declared-kind - * resource, and publishes `useRemoteAccess(action)` on the global resource. - * Domain UI adapters publish their own resource Hooks over `source()`. Every + * resource and whose `whenAccess(action, callback)` keeps effects installed + * while the global resource allows an action, and publishes + * `useRemoteAccess(action)` on the global resource. Domain UI adapters + * publish their own resource Hooks over `source()` and their own + * `whenAccess` over `whenResourceAccess()`. Every * answer is a boolean: true for the owner, false without a snapshot or after * a non-owner stream fails; the Host enforces every call regardless. * @module @deepseek-ai/dsh-api-remote-access/client @@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts' export type { RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, } from '../types.ts' -export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' +export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts' /** Required Client services: the Remote mount face and the Slot registry. */ export const inject = ['remote', 'slots'] diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts index 257aa396c2..68b868a8a7 100644 --- a/packages/api/remote-access/src/client/mount.ts +++ b/packages/api/remote-access/src/client/mount.ts @@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteAccessFrame } from '../types.ts' import { ClientAccessState, type Baseline } from './state.ts' +import { whenAllowed, type AccessEffect } from './when-allowed.ts' + +export type { AccessEffect } from './when-allowed.ts' /** Every endpoint the Client Remote namespaces declare. */ export type RemoteEndpoint = Extract @@ -29,12 +32,38 @@ export interface ClientRemoteAccess { * Whether the current Peer may call `action` on one resource. * True for the owner; false without a baseline and after a non-owner * stream fails; otherwise whether the resource's grant lists the action. + * Only the package that owns a resource kind calls it, to build that kind's + * access Hook; other plugins read access through the access Hooks or + * `whenAccess`. * @param kind - resource kind declared in `TypertResourceMap`. * @param id - resource identity. * @param action - endpoint action the caller would invoke. * @returns identity-stable boolean source for this `(kind, id, action)`. */ source(kind: TypertResourceKind, id: string, action: string): HostObservable + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on the global resource: run it when the answer becomes true, + * dispose its effects in reverse order when it becomes false, and run it + * again on the next true. The callback runs synchronously when the answer + * is already true, as it always is for the owner. Effect forms and fiber + * ownership match `ctx.slots.inject`. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void + /** + * {@link whenAccess} for one resource of a declared kind. Only the package + * that owns a resource kind calls it, to build that kind's `whenAccess`, as + * it builds the kind's access Hook over {@link source}. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess { source(kind: TypertResourceKind, id: string, action: string): HostObservable { return this.state.resource(kind, id, action) } + + // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context. + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`) + } + + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback, + `remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`) + } } /** diff --git a/packages/api/remote-access/src/client/when-allowed.ts b/packages/api/remote-access/src/client/when-allowed.ts new file mode 100644 index 0000000000..e1c2ba890c --- /dev/null +++ b/packages/api/remote-access/src/client/when-allowed.ts @@ -0,0 +1,85 @@ +/** + * The effect controller behind `whenAccess`: install a callback's effects + * while one access answer is true and dispose them while it is false. + * @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */ +export type AccessEffect = (() => void) | Iterable<() => void, void, void> + +/** + * Install `callback`'s effects while `answer` is true. The callback runs + * synchronously when the answer is already true; a later true runs it again + * after a false disposed the previous installation in reverse order. The + * controller belongs to `ctx`'s fiber. + * @param ctx - the caller's Context; it owns the controller and every installation. + * @param answer - the access answer to follow. + * @param callback - creates one disposer or an iterable of disposers. + * @param label - effect label for diagnostics. + * @returns idempotent disposer for the controller and any installed effects. + * @throws callback setup failures synchronously when the answer is already true. + */ +export function whenAllowed( + ctx: Context, + answer: HostObservable, + callback: () => AccessEffect, + label: string, +): () => void { + const disposeController = ctx.effect(() => { + let installed: (() => void) | undefined + let stopped = false + + const uninstall = (): void => { + const dispose = installed + installed = undefined + dispose?.() + } + + const reconcile = (): void => { + if (stopped) return + const allowed = answer.getSnapshot() + if (allowed === (installed !== undefined)) return + if (!allowed) { + uninstall() + return + } + // Each installation is a nested Cordis effect, so an iterable callback + // installs transactionally and disposes in reverse order. + const disposeEffect = ctx.effect(callback, `${label}: allowed`) + installed = () => { void disposeEffect() } + } + + const unsubscribe = answer.subscribe(() => { changed() }) + + const stop = (): void => { + if (stopped) return + // A failed installation retires the controller: a later grant never retries it. + stopped = true + unsubscribe() + uninstall() + } + + const changed = (): void => { + try { + reconcile() + } catch (error) { + stop() + if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return + const failure = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => { throw failure }) + } + } + + try { + reconcile() + } catch (error) { + stop() + throw error + } + return stop + }, label) + return () => { void disposeController() } +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts index 5b3cf41e0f..98ee668ca4 100644 --- a/packages/api/remote-access/src/index.ts +++ b/packages/api/remote-access/src/index.ts @@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'remoteAccess') ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') - // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + // Every index opens with the access snapshot of the Peer it speaks for. ctx.inject(['webServer'], (webCtx) => { - webCtx.on('webserver/index-inject', (table) => { - table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + webCtx.on('webserver/index-inject', (table, peer) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) }) }) }) } @@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService { } /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts index b73944ec58..5a70579b01 100644 --- a/packages/api/remote-access/tests/client-access.client.spec.ts +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh- import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' import { mountAccess } from '../src/client/mount.ts' +import { whenAllowed } from '../src/client/when-allowed.ts' declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertResourceMap { @@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'test-shelf': TypertResource 'test-desk': TypertResource } + interface TypertRemoteMap { + /** Test-only endpoint the `whenAccess` specs guard effects on. */ + 'test/guarded': () => Promise + } } const GRANTED: Extract = { @@ -289,3 +294,223 @@ describe('remote-access Client mount', () => { await access.dispose() }) }) + +/** A baseline whose grants allow `test/guarded` on the listed resources. */ +function followOn(...resources: RemoteAccessGrant['resource'][]): Extract { + return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) } +} + +describe('remote-access whenAccess', () => { + it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => { + const access = await mounted() + const log: string[] = [] + const guarded = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', function* () { + log.push('install') + yield () => { log.push('dispose first') } + yield () => { log.push('dispose second') } + }) + }, + }) + await guarded.await() + expect(log).toEqual([]) + + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(log).toEqual(['install']) }) + // A frame that leaves the answer unchanged installs nothing again. + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(log).toHaveLength(4) }) + expect(log[3]).toBe('install') + + await guarded.dispose() + expect(log.slice(4)).toEqual(['dispose second', 'dispose first']) + await access.dispose() + }) + + it('runs the callback at once for the owner and disposes it with the returned disposer', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const dispose = vi.fn() + const callback = vi.fn(() => dispose) + const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback) + expect(callback).toHaveBeenCalledOnce() + stop() + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('follows one resource of a declared kind through whenResourceAccess', async () => { + const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' })) + const dispose = vi.fn() + const shelf = vi.fn(() => dispose) + const desk = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf) + access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk) + expect(shelf).toHaveBeenCalledOnce() + expect(desk).not.toHaveBeenCalled() + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('disposes a non-owner\'s effects once its stream fails terminally', async () => { + const access = await mounted(followOn({ kind: 'global' })) + vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const dispose = vi.fn() + access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose) + access.stream().fail(new Error('carrier closed for good')) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => { + const owner = await mounted({ type: 'baseline', owner: true }) + expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') })) + .toThrow('setup failed') + await owner.dispose() + + const access = await mounted() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const failing = vi.fn((): () => void => { throw new Error('late failure') }) + const throwsValue = vi.fn(function* (): Generator<() => void> { + yield () => {} + throw new Error('late value') + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', failing) + access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue) + access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value']) + expect(later).toHaveBeenCalledOnce() + access.stream().push({ type: 'remove', resource: { kind: 'global' } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) }) + expect(failing).toHaveBeenCalledOnce() + expect(throwsValue).toHaveBeenCalledOnce() + } finally { + process.off('uncaughtException', onLoud) + } + await access.dispose() + }) + + it('skips a controller another installation stopped during the same notification', async () => { + const access = await mounted() + let stopLater = (): void => {} + const first = vi.fn(() => { + stopLater() + return () => {} + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', first) + stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() }) + expect(later).not.toHaveBeenCalled() + await access.dispose() + }) + + it('installs nothing for a grant that arrives while its caller is unloading', async () => { + const access = await mounted() + const callback = vi.fn(() => () => {}) + let release = (): void => {} + const blocked = new Promise((resolve) => { release = resolve }) + const pauseUnload = vi.fn(async () => { await blocked }) + const caller = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', callback) + ctx.effect(() => pauseUnload, 'pause caller unload') + }, + }) + await caller.await() + const disposing = caller.dispose() + await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() }) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) }) + expect(callback).not.toHaveBeenCalled() + release() + await disposing + await access.dispose() + }) +}) + +/** A hand-driven access answer that notifies synchronously. */ +function drivenAnswer(): { readonly answer: HostObservable; set(allowed: boolean): void } { + let allowed = false + const listeners = new Set<() => void>() + return { + answer: { + getSnapshot: () => allowed, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + set(next) { + allowed = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('whenAllowed', () => { + it('installs nothing when the answer turns true while its caller is unloading', async () => { + const root = new Context() + const driven = drivenAnswer() + const callback = vi.fn(() => () => {}) + const caller = root.plugin({ + apply: (ctx: Context) => { + whenAllowed(ctx, driven.answer, callback, 'test') + // Disposed before the controller: it turns the answer true mid-unload. + ctx.effect(() => () => { driven.set(true) }, 'grant during unload') + }, + }) + await caller.await() + await caller.dispose() + expect(callback).not.toHaveBeenCalled() + await root.fiber.dispose() + }) + + it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => { + const root = new Context() + const driven = drivenAnswer() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test') + driven.set(true) + await vi.waitFor(() => { expect(failures).toHaveLength(1) }) + expect(() => { stop() }).not.toThrow() + // A faulty answer that throws a non-Error value is reported as an Error naming it. + let reads = 0 + const listeners: (() => void)[] = [] + whenAllowed(root, { + getSnapshot: () => { + reads++ + if (reads > 1) throw 'unreadable answer' + return false + }, + subscribe: (listener) => { + listeners.push(listener) + return () => {} + }, + }, () => () => {}, 'test') + for (const listener of listeners) listener() + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(String(failures[1])).toBe('Error: unreadable answer') + } finally { + process.off('uncaughtException', onLoud) + } + await root.fiber.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts index f29d4d1744..101991e0c4 100644 --- a/packages/api/remote-access/tests/remote-access.host.spec.ts +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -400,18 +400,27 @@ describe('first snapshots', () => { expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') }) - it('injects the owner baseline into every index the webServer renders', async () => { - const { ctx } = await setup() - const render = (): IndexInjection[] => { + it('injects the access snapshot of the Peer each index speaks for', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + const render = (principal: Principal): IndexInjection[] => { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal })) return table } - expect(render()).toEqual([]) + expect(render(OWNER)).toEqual([]) ctx.provide('webServer', {} as Context['webServer']) await vi.waitFor(() => { - expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) }) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + } }]) + // A grant change reaches the next render. + policy.hold(GUEST) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }]) }) it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json index 0ddeaeac30..c365c4f780 100644 --- a/packages/api/remote-access/tsconfig.client.json +++ b/packages/api/remote-access/tsconfig.client.json @@ -9,6 +9,7 @@ "src/client/index.ts", "src/client/mount.ts", "src/client/state.ts", + "src/client/when-allowed.ts", "src/snapshot.ts", "src/types.ts" ], diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index dcf2bcff18..9af5ca6a4f 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf' import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { expect, it, onTestFinished, vi } from 'vitest' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) @@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry])) for (const row of configured) await entries.get(row.id)!.fiber!.await() const injections: IndexInjection[] = [] - ctx.emit('webserver/index-inject', injections) + ctx.emit('webserver/index-inject', injections, PAGE_PEER) expect(injections).toEqual([ { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig }, ]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b3793c9cfd..c3497a95c2 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -22,6 +22,9 @@ import { import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Structural webServer fake recording both route registries. */ function fakeHttpServer( routes: WebRoute[], @@ -272,7 +275,7 @@ describe('connection node half', () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, @@ -281,7 +284,7 @@ describe('connection node half', () => { }]) await dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await dispose() diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts index 13ad7bc00a..ec507217c5 100644 --- a/packages/client/shortcuts/tests/config.host.spec.ts +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -1,8 +1,11 @@ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it } from 'vitest' import { Config, apply } from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('validates positive sequence timing and publishes it through the product page injection', async () => { expect(Config({}).stopSequenceMs).toBe(500) expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) @@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page try { apply(ctx, Config({ stopSequenceMs: 800 })) const table: IndexInjection[] = [] - await ctx.parallel('webserver/index-inject', table) + await ctx.parallel('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 6f54248246..ff25c9ae17 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index efec56fc49..1bdcafecdd 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,5 +1,7 @@ /** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ -export const inject = ['slots', 'uiWorkspace'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */ +export const inject = ['slots', 'uiWorkspace', 'remoteAccess'] /** - * Client plugin body: register the renderless native flow into both - * directory-flow holes through `slots.inject()` because the ui-workspace - * entries may activate later or replace their declarations. + * Client plugin body: while the current Peer may open the Host's directory + * chooser, register the renderless native flow into both directory-flow holes + * through `slots.inject()` because the ui-workspace entries may activate later + * or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void { // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. - ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => - ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { - yield ctx.slots.register({ - name: 'conversation.hero.workspace.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - yield ctx.slots.register({ - name: 'sidebar.workspaces.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - })) + ctx.remoteAccess.whenAccess('directoryPicker/pick', () => + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + }))) } diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index ed033999aa..fd3b22d315 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry @@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'uiWorkspace']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess']) + }) + + it('fills no directory flow while the current Peer may not open the Host chooser', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('directoryPicker/pick') + for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([]) + await fiber.dispose() + await b.ctx.fiber.dispose() }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 6709f79984..c95a28d21b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' /** - * One message's feedback controls. - * @param props - the owner's message identity, the injected verbs, and the - * shared feedback hook. - * @returns the rating buttons with any failure notice beside them. + * One message's feedback controls, rendered only while the current Peer may + * record message feedback in this Session. + * @param props - the owner's message identity, the injected verbs, the + * shared feedback hook, and the Session access Hook. + * @returns the rating buttons with any failure notice beside them, or nothing without access. */ -export function MessageFeedbackActions({ +export function MessageFeedbackActions(props: MessageFeedbackActionProps) { + return props.useAccess('messageFeedback/put') ? : null +} + +function FeedbackRatingButtons({ messageId, ensure, current, retract, openDialog, useFeedback, t, }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 4121676697..2e80707a58 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -45,6 +45,8 @@ function mount(options: { ensureResult?: MessageFeedbackActionResult retractResult?: MessageFeedbackActionResult status?: MessageFeedbackView['status'] + /** The Session access answer for recording message feedback; allowed when omitted. */ + allowed?: boolean } = {}) { const view: MessageFeedbackView = { status: options.status ?? 'ready', @@ -58,12 +60,22 @@ function mount(options: { const current = vi.fn((_id: MessageId) => options.committed ?? options.current) const useFeedback = ((select: (v: MessageFeedbackView) => T): T => useSyncExternalStore(() => () => {}, () => select(view))) as never + const useAccess = vi.fn((_action: string) => options.allowed ?? true) const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as Parameters[0] - return { ...render(), ensure, retract, openDialog } + return { ...render(), ensure, retract, openDialog, useAccess } } describe('MessageFeedbackActions', () => { + it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => { + const ui = mount({ allowed: false }) + + expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put') + expect(ui.queryByLabelText(zh['action.like'])).toBeNull() + expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull() + expect(ui.ensure).not.toHaveBeenCalled() + }) + it('renders both rating buttons unpressed with no recorded feedback', () => { const ui = mount() @@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..a9eceb424d 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 52506f9fb7..6682477943 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves @@ -79,12 +81,13 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { @@ -93,82 +96,77 @@ export function apply(ctx: ClientContext): void { const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text)) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, - inject: (): PluginRefreshToastFace => ({ - hooks: { pluginManager: face.hooks.pluginManager }, - dismissNotice: face.dismissNotice, - }), - }, PluginRefreshToast)) - ctx.slots.inject('main', function* () { - const handle = createNavigationStore(), instance = handle.create() - const store: typeof handle = { ...handle, create: () => instance } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => face, + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => face, - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 83f72145d7..3a62e8c5ce 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -21,10 +21,11 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -153,7 +154,20 @@ describe('ui-plugin-manager browser plugin', () => { }) it('declares only the services the page and its Remote methods use', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) + }) + + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('pluginManager/listBundles') + for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([]) + expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false) + await fiber.dispose() }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index dce1c30814..5d149a8d39 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 4aa83ae1a8..8c6dcb3c5f 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */ +/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */ export const inject = [ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ] /** * Register the Host task page, the Session-header reminder catalog, the * right-Sidebar page that shows one task's detail, and the transcript card of - * one created task. + * one created task while the current Peer may read the task catalog. * @param ctx - browser services used by these contributions. */ export function apply(ctx: ClientContext): void { @@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void { const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa } ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries') const t = ctx.locale.bind(MANAGER_NS) - const manager: CatalogInjected = createCatalogSource({ - list: () => ctx.remote.schedule.catalog(), - remove: async (id) => { - const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) - if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } - return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) - }, - subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), - subscribeReset: listener => ctx.on('connection/reset', listener), - }) - // One outcome store behind one `shell.overlay` entry announces every deletion - // this plugin's surfaces settle, so the notice outlives the panel, popover, or - // tab the deletion started in. - const deleteToast = createDeleteToastSource() - const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => - async (id: ScheduleId): Promise => { - const outcome = await onDelete(id) - deleteToast.report(outcome) - return outcome - } - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, - inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), - }, ScheduleDeleteToast)) - /** - * Forward one compare-and-update request, which may replace the task name, - * the instruction, and/or the timing, and refresh the authoritative catalog - * after an accepted or stale outcome. - * @param request - complete expected record with the optional content and timing change. - * @returns the original Remote mutation result. - */ - const updateTask = async (request: ScheduleUpdateRequest) => { - const result = await ctx.remote.schedule.update(request) - if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' - || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { - // The write is in, so this readback must not join a read that was sent - // before it: naming every request seen so far makes the source issue a new - // one, the way the post-deletion readback supersedes. - await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) - } - return result - } - const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) - const openSession = (id: SessionId): void => { - if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { - ctx.uiWorkspace.openSession(id) - } - } - const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { - hooks: manager.hooks, - onDelete: reportedDelete(manager.onDelete), - onRetry: manager.onRetry, - onUpdateTiming: updateTask, - loadHistory, - onOpenSession: openSession, - } - // The Sidebar persists a tab's layout record and not its navigation - // parameters, so this page type keeps the task it last showed per Session and - // tab id. That Session's committed tab ids bound every write, so a closed tab - // leaves no entry behind. - const taskBindings = new TaskTabBindings( - sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), - ) ctx.effect(() => ctx.sidebarRightTabs.register(scheduleTaskDefinition(t)), 'ui-schedule: task tab type') - ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), - }, ScheduleTaskTab)) - ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab.title', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), - }, ScheduleTaskTabTitle)) - ctx.slots.inject('main', () => ctx.slots.register({ - name: 'main', - key: PANEL_ID, - locale: MANAGER_NS, - inject: (): TaskManagerInjected => ({ - ...detail, - // The page has no creation form: a new reminder starts in a Session. - onNewTask: () => { ctx.uiWorkspace.startSession() }, - }), - }, TaskManagerPage)) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 10, - locale: MANAGER_NS, - label: () => t('panel'), - }, TaskManagerIcon)) // The created task is a Turn-level element, not a Tool-group row: the Turn - // Definition publishes the settled result and this tail list entry renders - // the card beneath the closing prose. + // Definition publishes the settled result and the tail list entry below + // renders the card beneath the closing prose. ctx.uiConversation.events.register(scheduleTurnDefinition) - ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ - name: 'conversation.chat.turnTail', - id: 'schedule-created', - order: 20, - locale: MANAGER_NS, - inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ - hooks: { catalog: manager.hooks.catalog }, + ctx.remoteAccess.whenAccess('schedule/catalog', function* () { + const manager: CatalogInjected = createCatalogSource({ + list: () => ctx.remote.schedule.catalog(), + remove: async (id) => { + const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) + if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } + return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) + }, + subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), + subscribeReset: listener => ctx.on('connection/reset', listener), + }) + // One outcome store behind one `shell.overlay` entry announces every deletion + // this plugin's surfaces settle, so the notice outlives the panel, popover, or + // tab the deletion started in. + const deleteToast = createDeleteToastSource() + const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => + async (id: ScheduleId): Promise => { + const outcome = await onDelete(id) + deleteToast.report(outcome) + return outcome + } + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, + inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), + }, ScheduleDeleteToast)) + /** + * Forward one compare-and-update request, which may replace the task name, + * the instruction, and/or the timing, and refresh the authoritative catalog + * after an accepted or stale outcome. + * @param request - complete expected record with the optional content and timing change. + * @returns the original Remote mutation result. + */ + const updateTask = async (request: ScheduleUpdateRequest) => { + const result = await ctx.remote.schedule.update(request) + if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' + || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { + // The write is in, so this readback must not join a read that was sent + // before it: naming every request seen so far makes the source issue a new + // one, the way the post-deletion readback supersedes. + await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) + } + return result + } + const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) + const openSession = (id: SessionId): void => { + if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { + ctx.uiWorkspace.openSession(id) + } + } + const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { + hooks: manager.hooks, + onDelete: reportedDelete(manager.onDelete), onRetry: manager.onRetry, - openTaskDetail: (id) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - }), - }, ScheduleTurnCard)) - // The Session header keeps one per-Session source: it lists the open - // Session's active tasks and starts only while that header is mounted. The - // Sidebar row seats read the shared `manager` catalog instead, so every - // visible row projects one Host query rather than issuing its own. - const createSource: SessionScheduleSourceFactory = sessionId => - createSessionScheduleSource(ctx, sessionId) - ctx.slots.inject( - 'conversation.session.header.utilities', - () => ctx.slots.register({ - name: 'conversation.session.header.utilities', - id: 'schedule-catalog', - // Sits left of the overflow menu (order 0) and right of the header's - // leading utilities (order -10), matching the shipped mock. - order: -5, + onUpdateTiming: updateTask, + loadHistory, + onOpenSession: openSession, + } + // The Sidebar persists a tab's layout record and not its navigation + // parameters, so this page type keeps the task it last showed per Session and + // tab id. That Session's committed tab ids bound every write, so a closed tab + // leaves no entry behind. + const taskBindings = new TaskTabBindings( + sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), + ) + yield ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), + }, ScheduleTaskTab)) + yield ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), + }, ScheduleTaskTabTitle)) + yield ctx.slots.inject('main', () => ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: MANAGER_NS, + inject: (): TaskManagerInjected => ({ + ...detail, + // The page has no creation form: a new reminder starts in a Session. + onNewTask: () => { ctx.uiWorkspace.startSession() }, + }), + }, TaskManagerPage)) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 10, + locale: MANAGER_NS, + label: () => t('panel'), + }, TaskManagerIcon)) + yield ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'schedule-created', + order: 20, + locale: MANAGER_NS, + inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ + hooks: { catalog: manager.hooks.catalog }, + onRetry: manager.onRetry, + openTaskDetail: (id) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + }), + }, ScheduleTurnCard)) + // The Session header keeps one per-Session source: it lists the open + // Session's active tasks and starts only while that header is mounted. The + // Sidebar row seats read the shared `manager` catalog instead, so every + // visible row projects one Host query rather than issuing its own. + const createSource: SessionScheduleSourceFactory = sessionId => + createSessionScheduleSource(ctx, sessionId) + yield ctx.slots.inject( + 'conversation.session.header.utilities', + () => ctx.slots.register({ + name: 'conversation.session.header.utilities', + id: 'schedule-catalog', + // Sits left of the overflow menu (order 0) and right of the header's + // leading utilities (order -10), matching the shipped mock. + order: -5, + locale: NS, + inject: (sessionId) => { + const source = createSource(sessionId) + return { + ...source, + // The header's deletions settle through the same app-wide toast as the + // Tasks page and the task tab. + onDelete: reportedDelete(source.onDelete), + openTaskDetail: (id: ScheduleId) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + } + }, + }, ScheduleCatalogAction), + ) + yield ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ + name: 'sidebar.session.row.leading', + id: 'schedule-mark', + order: 10, locale: NS, - inject: (sessionId) => { - const source = createSource(sessionId) - return { - ...source, - // The header's deletions settle through the same app-wide toast as the - // Tasks page and the task tab. - onDelete: reportedDelete(source.onDelete), - openTaskDetail: (id: ScheduleId) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - } - }, - }, ScheduleCatalogAction), - ) - ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ - name: 'sidebar.session.row.leading', - id: 'schedule-mark', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleMark)) - ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ - name: 'sidebar.session.row.hover', - id: 'schedule-tasks', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleHover)) + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleMark)) + yield ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ + name: 'sidebar.session.row.hover', + id: 'schedule-tasks', + order: 10, + locale: NS, + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleHover)) + }) } diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..768c478bef 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { StaticRemoteAccess, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' @@ -86,9 +86,11 @@ class RemoteStub extends Service { async function baseContext( remote: RemoteStubInput = { $on: () => () => {} }, sidebar: SidebarStubs = sidebarStubs(), + allows: (action: string) => boolean = () => true, ): Promise { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) new UiConversation(ctx, { binding: () => undefined } as never) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) @@ -140,10 +142,29 @@ describe('ui-schedule browser half', () => { it('declares only the services used by registration', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ]) }) + it('contributes its surfaces only while the current Peer may read the task catalog', async () => { + for (const allowed of [false, true]) { + const sidebar = sidebarStubs() + const allows = vi.fn(() => allowed) + const ctx = await baseContext(undefined, sidebar, allows) + const header = declareHeader(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('schedule/catalog') + // The Session-header reminder catalog is this plugin's entry point into the page. + expect(headerEntryIds(ctx)).toEqual(allowed ? ['schedule-catalog'] : []) + // The task tab type is a definition, registered either way. + expect(sidebar.register).toHaveBeenCalledOnce() + await fiber.dispose() + header() + await ctx.fiber.dispose() + } + }) + it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => { const ctx = await baseContext() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 447f8f489b..3a3d92d9d5 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: 12e1ffc25a2d6695 zh: 321156dea1356e55 /deepseek-ai-dsh-client-ui-session/access-hooks: - en: 619ba442c3e40fb0 - zh: af0206a774ac9bc1 + en: 7bf4bca5607248fa + zh: 665d5c4e9cd29f68 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 79ac9f0e73..4c3928b35d 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Access Hooks -The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. `ctx.uiSession.whenAccess(sessionId, action, callback)` is the JS counterpart of `useSessionAccess`: it keeps the callback's effects installed while the current Peer may call `action` on that Session, with the semantics of `ctx.remoteAccess.whenAccess`. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 157cc79a7f..fb63e227cd 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" ## 访问 Hook -本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。`ctx.uiSession.whenAccess(sessionId, action, callback)` 是 `useSessionAccess` 在 JS 侧的对应:当前 Peer 能在该 Session 上调用 `action` 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 模型体验 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 29aeebce04..860a82287c 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,7 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -360,6 +360,24 @@ export class UiSession extends Service { return this.sourceFor(owner) } + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Session resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useSessionAccess` answers the same + * question during render. + * @param sessionId - the Session resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(sessionId: SessionId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiSession is reachable. */ + if (access === undefined) throw new Error('ui-session: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('session', sessionId, action, callback) + } + /** * Register one Session-scoped standard-source contribution. * @param descriptor - static member roster and per-binding resolver. diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index ae105e7b8e..776db63bed 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -728,7 +728,10 @@ describe('ui-session apply', () => { } return source }), + whenAccess: vi.fn(() => () => {}), + whenResourceAccess: vi.fn((): () => void => stopGuard), } + const stopGuard = vi.fn() ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) ctx.provide('remoteAccess', remoteAccess) @@ -758,6 +761,11 @@ describe('ui-session apply', () => { const binding = bench.binding(sessionId('s2')) const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) + + // `whenAccess` follows the same Session resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(ctx.uiSession.whenAccess(sessionId('s1'), 'session/fork', guarded)).toBe(stopGuard) + expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded) }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 681232a393..41b221656b 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 4847b9b6e9..db0901ee9b 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { OnboardingChange } from './onboarding-contract.ts' @@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms', 'remoteAccess'] /** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */ export function apply(ctx: Context): void { const desktop = 'dshDesktop' in globalThis @@ -115,37 +116,40 @@ export function apply(ctx: Context): void { void request.finally(() => { if (refreshing === request) refreshing = undefined }) return request } - ctx.effect(() => () => { revision++ }, 'account: details request lifetime') - const stream = ctx.remote.$stream({ - name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), + // The account stream, and the reads it starts, follow access to this host's account state. + ctx.remoteAccess.whenAccess('account/watch', function* () { + yield () => { revision++ } + const stream = ctx.remote.$stream({ + name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), + }) + let disposed = false + yield () => { disposed = true; notices.end(); return stream.dispose() } + void (async () => { + for await (const frame of stream) { + revision++ + refreshing = undefined + avatar.reset() + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + const { notices: previousNotices, ...previous } = snapshot + publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, + ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) + browser.update(frame.value) + frame.accept() + if (frame.value.status === 'credential-stored') notices.begin() + else notices.end() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel() + if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() + void refresh() + } + })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) }) - let disposed = false - ctx.effect(() => () => { disposed = true; return stream.dispose() }, 'account: state stream') - void (async () => { - for await (const frame of stream) { - revision++ - refreshing = undefined - avatar.reset() - const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded' - const { notices: previousNotices, ...previous } = snapshot - publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, - ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) - browser.update(frame.value) - frame.accept() - if (frame.value.status === 'credential-stored') notices.begin() - else notices.end() - if (initialize) void (async () => { - try { - const initialized = await ctx.remote.session.initializeDefaultModel() - if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) - } catch (_error) { - console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) - } - })() - void refresh() - } - })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform // One request channel for the single native Platform view. It always exists // so every entry can observe whether a page is showing; only a native bridge @@ -290,14 +294,14 @@ export function apply(ctx: Context): void { ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, }, AccountOnboarding)) - ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ name: 'shell.quota-notice', locale: 'settings.account', select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, inject: (): AccountQuotaNoticeInjected => ({ hooks: { account: operations.hooks.account, platformPage: platformPages }, ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, }), - }, AccountQuotaNotice)) + }, AccountQuotaNotice))) if (nativePlatform !== undefined) { ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account', @@ -307,9 +311,9 @@ export function apply(ctx: Context): void { }), }, AccountPlatformHost)) } - ctx.slots.inject('settings.launcher', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, - }, AccountMenu)) + }, AccountMenu))) ctx.slots.inject('settings.section', () => { let unregister: (() => void) | undefined const update = () => { diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 0146bbfa4a..5eb29a1ade 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -23,6 +23,7 @@ import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx' import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx' const it = createClientTest({ roster: webApp }) +const withoutAccountAccess = createClientTest({ roster: webApp }, { access: action => action !== 'account/watch' }) const SELF = '@deepseek-ai/dsh-client-ui-settings-account' const view: AccountView = { status: 'signed-out', attempt: null, @@ -77,6 +78,15 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac } }, 60_000) +withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => { + const c = await start() + expect(c.mock.streams.end('account/watch')).toBe(0) + expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) + expect(quotaNoticeEntry(c)).toBeUndefined() + // The models sign-in entry lives inside the settings shell, which follows its own access. + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) +}, 60_000) + it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/config.client.spec.ts b/packages/client/ui-settings-account/tests/config.client.spec.ts index a5a33dca61..850cf78660 100644 --- a/packages/client/ui-settings-account/tests/config.client.spec.ts +++ b/packages/client/ui-settings-account/tests/config.client.spec.ts @@ -1,24 +1,27 @@ /** Public questionnaire options are emitted in the page bootstrap. */ import { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config } from '../src/index.ts' import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('injects configured contact options and removes the listener on disposal', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) const config: Config = Config({ contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) await ctx.plugin({ Config, apply }, { contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: CONTACT_CONFIG_GLOBAL, value: { contactFormUrl: config.contactFormUrl, contactSource: config.contactSource, bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100, } }]) await ctx.fiber.dispose() table.length = 0 - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([]) }) diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index daa962d263..a17217f0d2 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -44,6 +44,14 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize return () => {} }, }, + remoteAccess: { + whenAccess: (_action: string, install: () => (() => void) | Iterable<() => void> | undefined) => { + const effect = install() + if (typeof effect === 'function') disposers.push(effect) + else if (effect !== undefined) for (const dispose of effect) disposers.push(dispose) + return () => {} + }, + }, remote: { $on: () => () => {}, session: { initializeDefaultModel }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 29d5e63785..944ce57932 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 7183dab668..180df12f10 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -9,6 +9,8 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the ctx.remote merge and its fixed Host facts. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -70,7 +72,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -185,46 +187,48 @@ export function apply(ctx: ClientContext): void { }, }, }) - ctx.slots.inject('sidebar.settings', () => { - const shellHandle = createSettingsShellStore() - const shellInstance = shellHandle.create() - const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } - const disposeCommand = ctx.shortcuts.register({ - id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], - defaults: { - 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, - 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, - 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, - }, - regions: ['page', 'editable', 'terminal'], modals: ['settings'], - resolve: ({ modal }) => { - if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } - return { status: 'handled', run: () => { - if (modal === 'settings') closeTopModal(document) - else shellInstance.actions.open() - } } - }, - }) + // The settings shell reads this host's configuration, so it follows access to its description. + ctx.remoteAccess.whenAccess('settings/describe', () => + ctx.slots.inject('sidebar.settings', () => { + const shellHandle = createSettingsShellStore() + const shellInstance = shellHandle.create() + const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } + const disposeCommand = ctx.shortcuts.register({ + id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], + defaults: { + 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, + 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable', 'terminal'], modals: ['settings'], + resolve: ({ modal }) => { + if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } + return { status: 'handled', run: () => { + if (modal === 'settings') closeTopModal(document) + else shellInstance.actions.open() + } } + }, + }) - const disposeSlot = ctx.slots.register({ - name: 'sidebar.settings', - locale: NS, - store: shellStore, - children: { - 'settings.launcher': { kind: 'single', scope: 'root' }, - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: shellInjected, - }, SettingsRoot) - return () => { disposeCommand(); disposeSlot() } - }) + const disposeSlot = ctx.slots.register({ + name: 'sidebar.settings', + locale: NS, + store: shellStore, + children: { + 'settings.launcher': { kind: 'single', scope: 'root' }, + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: shellInjected, + }, SettingsRoot) + return () => { disposeCommand(); disposeSlot() } + })) ctx.slots.inject('settings.trigger', () => ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 691a9ca717..2b3a09a468 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -96,7 +96,7 @@ function setPageUrl(url: string): void { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 47b7460e2c..a072a81df9 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -16,6 +16,7 @@ import type { DesktopUpdatePresentation } from '../src/types.ts' const SELF = '@deepseek-ai/dsh-client-ui-settings-general' const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' const it = createClientTest({ roster: webApp }) +const withoutSettingsAccess = createClientTest({ roster: webApp }, { access: action => action !== 'settings/describe' }) /** The whole roster's first boot pays the cold module transform of every plugin package. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -77,7 +78,7 @@ describe('ui-settings-general shell', () => { }, COLD_BOOT_TIMEOUT_MS) it('declares its services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { @@ -86,6 +87,11 @@ describe('ui-settings-general shell', () => { for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }, COLD_BOOT_TIMEOUT_MS) + withoutSettingsAccess('leaves sidebar.settings unoccupied while the current Peer may not read the settings description', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings')).toEqual([]) + }, COLD_BOOT_TIMEOUT_MS) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { const c = await start() const { sections } = injectedOf(c).hooks diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index f9dac62ceb..66e3eae78c 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -17,10 +17,13 @@ import { import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as hostPlugin from '../src/index.ts' import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + afterEach(() => { vi.unstubAllGlobals() }) // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, @@ -74,7 +77,7 @@ describe('ui-settings-models apply', () => { const host = ctx.plugin(hostPlugin, { credentialOnboarding: false }) await host.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }]) for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value) const plugin = ctx.plugin({ inject: [...inject], apply }) @@ -87,7 +90,7 @@ describe('ui-settings-models apply', () => { expect(slots.entries('settings.onboarding')).toEqual([]) await host.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await ctx.fiber.dispose() diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 6646008850..8a09bdd497 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -1,10 +1,13 @@ /** Host configuration supplies bounded Office reuse settings to browser pages. */ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it, onTestFinished } from 'vitest' import { Config } from '../src/config.ts' import * as host from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('exposes Client cache and spreadsheet limits on the Host entry', () => { expect(host.Config).toBe(Config) expect(Config({})).toEqual({ office: { @@ -22,10 +25,10 @@ it('embeds YAML cache settings in browser pages and withdraws them on disposal', const fiber = ctx.plugin(host, config) await fiber.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }]) await fiber.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 69a10570b8..0de2aac907 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as HostPlugin from '../src/index.ts' import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import { plainConfig } from '../../../settings/settings/src/schema.ts' @@ -8,11 +8,14 @@ import { DEFAULT_PREFERENCE, Config, apply, } from '@deepseek-ai/dsh-client-ui-theme' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Collect the injection table the way an index render or boot payload does. */ function collect(ctx: Context): IndexInjection[] { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) return table } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fb827521a7..34d19ae2bd 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 0ae90db57a09c62c - zh: 453f350f1dea2ac4 + en: 57f47add55b9f5c2 + zh: af7fe7f6b32cd30a /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 96a37e74555a36b7 zh: d03941523509a5cf diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 5849927e52..bc696e0520 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -83,7 +83,7 @@ Once both Workspace and Session startup baselines are ready, an empty installati The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. -The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource, and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`, which keeps the callback's effects installed while that answer is true, with the semantics of `ctx.remoteAccess.whenAccess`; snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a067dd0788..1c714bb93d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -83,7 +83,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 -本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`,以及 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`,在该答案为 true 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`;快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 55e03054ce..b4db2dd32e 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,6 +1,7 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, @@ -130,6 +131,17 @@ export interface UiWorkspace { * @returns created absolute path. */ createDirectory(path: string, name: string): Promise + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Workspace resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useWorkspaceAccess` answers the same + * question during render. + * @param workspaceId - the Workspace resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -406,6 +418,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { return result.value } + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiWorkspace is reachable. */ + if (access === undefined) throw new Error('ui-workspace: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('workspace', workspaceId, action, callback) + } + private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' const reconcile = (): void => { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index eade6885ff..1723a4decd 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -69,7 +69,9 @@ async function bench() { ctx.provide('uiConversation', {}) await ctx.plugin(SlotRegistry).await() const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) - ctx.provide('remoteAccess', { source: accessSource }) + const stopGuard = vi.fn() + const whenResourceAccess = vi.fn((): () => void => stopGuard) + ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -162,7 +164,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, workspacesSubscribe, initializeDefault, history, scope, sessionOf, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, @@ -233,6 +235,10 @@ describe('ui-workspace apply', () => { expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED) expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow') expect(keyed?.('w1')).toBeUndefined() + // `whenAccess` follows the same Workspace resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(b.ctx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', guarded)).toBe(b.stopGuard) + expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded) }) it('reports a default Workspace creation failure through the shared notice overlay', async () => { diff --git a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts index f4843bfcb5..2ecf5fe41e 100644 --- a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts +++ b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts @@ -13,6 +13,9 @@ import { apply, Config, inject, name } from '../src/index.ts' import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts' import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => { @@ -76,7 +79,7 @@ describe('experimental Inspector Host bootstrap route', () => { ) const injected = () => { const rows: IndexInjection[] = [] - context!.emit('webserver/index-inject', rows) + context!.emit('webserver/index-inject', rows, PAGE_PEER) return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') } return { fiber, bootstrap, injected, register, registerUpgrade, messages, log } diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index be1d0f9e2d..2496877a86 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -12,6 +12,9 @@ import { apply, Config, inject, name, startInspector } from '../src/index.ts' import { isPlainObject } from '../src/shared/json.ts' import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) interface CdpResponse { @@ -51,7 +54,7 @@ describe('experimental Inspector Host plugin', () => { await fiber.await() const rows: IndexInjection[] = [] - context.emit('webserver/index-inject', rows) + context.emit('webserver/index-inject', rows, PAGE_PEER) const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u)) @@ -116,7 +119,7 @@ describe('experimental Inspector Host plugin', () => { expect(releaseUpgrade).toHaveBeenCalledOnce() expect(rows).toHaveLength(1) const afterDispose: IndexInjection[] = [] - context.emit('webserver/index-inject', afterDispose) + context.emit('webserver/index-inject', afterDispose, PAGE_PEER) expect(afterDispose).toEqual([]) expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer)).status).toBe(404) }) diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 335edf56a5..5f19a032c3 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -26,7 +26,7 @@ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { HostConnectionHandle, PeerScope } from '@deepseek-ai/dsh-client-connection' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -273,7 +273,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const peer = connection.browserPeer tunnel.serve({ directFetch: (request: Request) => handler.fetch(request, peer), - bootPayload: () => readBootPayload(ctx), + bootPayload: () => readBootPayload(ctx, peer), packageAssets: () => packPackageAssets(mounted, root), openStream: (endpoint, payload, uplink, signal) => typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal), @@ -454,14 +454,15 @@ function prepareProfile( * from the in-process webserver service, never from the API surface, because * the page has no Cordis tree yet. * @param ctx - Booted host context. + * @param peer - the browser Peer the page speaks for. * @returns Boot payload for `GET /__boot__`. */ -function readBootPayload(ctx: HostContext): { injections: unknown } { - const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined +function readBootPayload(ctx: HostContext, peer: PeerScope): { injections: unknown } { + const webServer = ctx.get('webServer') as { collectIndexInjections(peer: PeerScope): unknown } | undefined if (webServer === undefined) { throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections') } - return { injections: webServer.collectIndexInjections() } + return { injections: webServer.collectIndexInjections(peer) } } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c770d2aca9..95424d8267 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2091,7 +2091,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'snapshotFor(principal: Principal): RemoteAccessBaseline', - description: 'The baseline a page opens with before its snapshot stream connects. A business package rendering a non-owner page assigns this value to REMOTE_ACCESS_SNAPSHOT_KEY after every index injection row, replacing the owner baseline the webServer index carries.', + description: 'The baseline a page opens with before its snapshot stream connects; every index the webServer renders carries it under REMOTE_ACCESS_SNAPSHOT_KEY for the Peer the page speaks for.', parameters: [{ name: 'principal', description: 'the page\'s caller identity.' }], returns: 'the owner baseline for the owner; otherwise the principal\'s current grants.', }, @@ -3858,15 +3858,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the transformed body.', }, { - signature: 'collectIndexInjections(): IndexInjection[]', - description: 'Gather the structured injection table: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference) at emit time.', - parameters: [], + signature: 'collectIndexInjections(peer: PeerScope): IndexInjection[]', + description: 'Gather the structured injection table for one Peer: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference, the Peer\'s access) at emit time.', + parameters: [{ name: 'peer', description: 'the Peer the page speaks for.' }], returns: 'rows in subscriber activation order.', }, { - signature: 'renderIndex(html: string): string', - description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.', - parameters: [{ name: 'html', description: 'the raw index.html body.' }], + signature: 'renderIndex(html: string, peer: PeerScope): string', + description: 'Render one index.html body for one Peer: the structured injection table first, then the raw `tapIndex` transforms over the result.', + parameters: [{ name: 'html', description: 'the raw index.html body.' }, { name: 'peer', description: 'the Peer the page speaks for.' }], returns: 'the transformed body.', }, ], @@ -4732,10 +4732,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'webserver/index-inject', mode: 'emit', - signature: '\'webserver/index-inject\'(table: IndexInjection[]): void', - summary: 'Collect the structured index injection table.', - description: 'Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.', - parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }], + signature: '\'webserver/index-inject\'(table: IndexInjection[], peer: PeerScope): void', + summary: 'Collect the structured index injection table for the page `peer` speaks for.', + description: 'Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.', + parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }, { name: 'peer', description: 'The Peer the rendered page speaks for.' }], }, { name: 'workflow/agent-end', diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 254fc82b3c..a53e4d9853 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -21,8 +21,8 @@ en: c58118ab4aba9bac zh: 334fcd54edcae152 /deepseek-ai-dsh-host-frontend-static/use-this-package/what-the-server-enforces: - en: 3bd43604d1195427 - zh: 4912adee677ac589 + en: 2bfa62a708bf6357 + zh: af117941f288207f /deepseek-ai-dsh-host-frontend-static/use-this-package/observable-failures: en: 7b6cca6ed823f515 zh: 30b5a5c594e8f1b8 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index 43e3d23e2c..c328a57b4b 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -39,7 +39,7 @@ Compose this plugin in a browser-facing host that serves the built Web shell: it ### What the server enforces -Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex`, so the boot manifest reaches the page on `/` and on the configured index path. +Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex` for `connection.browserPeer`, the Peer Connection's browser authentication admits, so the boot manifest and that Peer's access snapshot reach the page on `/` and on the configured index path. The served HTML carries one document base, ``, ahead of every injected resource row, so it freezes the entry directory the page was loaded from: the shell's own app-directory-relative references and the Host's plugin-resource rows both resolve under the mount that served the page. The same index therefore serves the origin root and whatever mount a prefix-stripping proxy owns; this plugin renders it only for the dist root and the configured index path. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index e3da3829cf..47230ca1ba 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ### 服务器实施的约束 -请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染,因此启动 manifest(元数据清单)会通过 `/` 与配置的 index 路径送达页面。 +请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 为 `connection.browserPeer`(Connection 浏览器认证所准入的 Peer)渲染,因此启动 manifest(元数据清单)与该 Peer 的访问快照会通过 `/` 与配置的 index 路径送达页面。 所服务的 HTML 携带唯一的文档 base ``,位于每一条注入资源行之前,因此它冻结页面加载时所处的入口目录:shell 自身的应用目录相对引用与宿主的插件资源行都在服务该页面的挂载下解析。同一份 index 因而既服务源站根目录,也服务剥离前缀的代理所拥有的任一挂载;本插件只为 dist 根目录与配置的 index 路径渲染它。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 71ccdd127c..bfd003ddc5 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -117,7 +117,8 @@ export function apply(ctx: Context, config: Config): void { const distRoot = dirname(distIndex) // Insert after all index transforms so the base precedes every resource reference. const renderIndex = async (): Promise => { - const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8')) + // Connection's browser authentication admits the owner's shared browser Peer. + const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'), ctx.connection.browserPeer) return body.replace(/]*)?>/i, open => `${open}`) } ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => { diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 61f92b3798..2e84218ae0 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -9,8 +9,8 @@ en: 9c6c5fcadd836722 zh: f5b79ea2c4be6dde /deepseek-ai-dsh-host-webserver/summary: - en: 0d1ffed238b25446 - zh: 9f66adce1c593c53 + en: e4daa778a9126818 + zh: f239eb0c8c7a624b /deepseek-ai-dsh-host-webserver/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -27,8 +27,8 @@ en: 296ffba200f0dc93 zh: 5d78d6b5f48634c9 /deepseek-ai-dsh-host-webserver/use-this-package/the-fallback-seat: - en: e21232b7aecbe7a8 - zh: 2c4a94cab2aa53ff + en: d7ecbb2b3ff9aa9b + zh: 08ebfd9798d96a62 /deepseek-ai-dsh-host-webserver/use-this-package/behavior-under-failure: en: dd2529dd54707e39 zh: 2615f469d66ac681 @@ -36,8 +36,8 @@ en: 42b15ac6321706aa zh: 4ed0861911abaf5d /deepseek-ai-dsh-host-webserver/understand-the-implementation/design-concept: - en: 728da97b91b797b6 - zh: 22541daf0223c663 + en: 88adbd5967b978d3 + zh: b4fabfd6f5df1b71 /deepseek-ai-dsh-host-webserver/understand-the-implementation/matching-and-lifecycle: en: 8589aba0cbaa1c76 zh: babf3b27b0cdb35e diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 8b03024639..d0eaef56d8 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. +Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. ## Table of Contents @@ -64,7 +64,7 @@ Set `compression: 'gzip'` to wrap eligible socket-backed responses without chang `registerFallback(handler)` claims the one handler for every request no named route matches. A second registration throws; while no fallback is registered the server answers 404. In the shipped Web composition the [SPA dist server](../frontend-static/README.md) owns the seat and calls `renderIndex` on every index response it renders. -Index startup inputs are two layers. `collectIndexInjections()` gathers a fresh injection table — one `webserver/index-inject` emit per call, each subscriber pushing its current rows — and `renderIndex(html)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses. +Index startup inputs are two layers, computed for the Peer the page speaks for. `collectIndexInjections(peer)` gathers a fresh injection table — one `webserver/index-inject` emit per call, passing `peer` to each subscriber, which pushes its current rows — and `renderIndex(html, peer)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses. ### Behavior under failure @@ -80,7 +80,7 @@ A listen failure (for example EADDRINUSE) rejects plugin initialization with the ### Design concept -The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source. +The package is a plain route registry whose only harness vocabulary is the Peer an index render speaks for: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source. ### Matching and lifecycle diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 29802a53a5..55f5e06a8f 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。 +浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。 ## 目录 @@ -64,7 +64,7 @@ kind: "package-reference" `registerFallback(handler)` 认领所有未被具名 route 命中的请求的唯一一个 handler。第二次注册会抛错;没有注册回退时服务器回答 404。在随附的 Web 组合中,[SPA dist 服务器](../frontend-static/README.zh.md)拥有该席位,并对其渲染的每个 index 响应调用 `renderIndex`。 -index 启动输入分两层。`collectIndexInjections()` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,每个订阅方推入其当前行——`renderIndex(html)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。 +index 启动输入分两层,按页面所代表的 Peer 计算。`collectIndexInjections(peer)` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,把 `peer` 传给每个订阅方,订阅方推入其当前行——`renderIndex(html, peer)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。 ### 失败时的行为 @@ -80,7 +80,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的 ### 设计理念 -本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。 +本包是一个普通路由注册表,唯一的 harness 词汇是 index 渲染所代表的 Peer:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。 ### 匹配与生命周期 diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 0dde2bb6d2..0477e4f679 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/compression": "^1.8.1", "@types/negotiator": "^0.6.5", "selfsigned": "^4.0.1" diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 73139550d3..5a70bdcb8c 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,8 +1,8 @@ /** * @deepseek-ai/dsh-host-webserver — node:http route registration — node:https - * when `tls` names a certificate and key — with optional gzip, index injection, - * and one fallback seat. It knows no harness concepts and serves no files; the - * composing application owns dist serving. Electron uses file:// plus IPC + * when `tls` names a certificate and key — with optional gzip, index injection + * for the Peer a page speaks for, and one fallback seat. It serves no files; + * the composing application owns dist serving. Electron uses file:// plus IPC * instead, and this package never prints the URL. * Route handlers retain direct response ownership. */ @@ -16,6 +16,7 @@ import { resolve } from 'node:path' import type { Duplex } from 'node:stream' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import compressionMiddleware from 'compression' import ipaddr from 'ipaddr.js' import Negotiator from 'negotiator' @@ -30,13 +31,15 @@ declare module '@deepseek-ai/cordis' { } interface Events { /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ - 'webserver/index-inject'(table: IndexInjection[]): void + 'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void } } @@ -564,25 +567,28 @@ export class WebServer extends Service { } /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ - collectIndexInjections(): IndexInjection[] { + collectIndexInjections(peer: PeerScope): IndexInjection[] { const table: IndexInjection[] = [] - this.ctx.emit('webserver/index-inject', table) + this.ctx.emit('webserver/index-inject', table, peer) return table } /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ - renderIndex(html: string): string { - return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections())) + renderIndex(html: string, peer: PeerScope): string { + return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections(peer))) } } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 3dad1dd472..20e0c395f7 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -21,6 +21,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import HttpServer, { bindUrlHost, isLoopbackHost, normalizeBindAddress, renderIndexInjections, type TlsConfig } from '../src/index.ts' import { materializeTlsFixture, type TlsFixture } from './tls-fixture.ts' @@ -516,11 +517,14 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) - it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + it('collects injection rows fresh per render for the rendered Peer and layers taps over the rendered rows', { timeout: 60_000 }, async () => { const loaded = await loadComposition() const server = loaded.webServer let flag = 'dark' - loaded.on('webserver/index-inject', (table) => { + const peer = { principal: { kind: 'owner' } } as PeerScope + const renderedFor: PeerScope[] = [] + loaded.on('webserver/index-inject', (table, renderingFor) => { + renderedFor.push(renderingFor) table.push( { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, @@ -532,7 +536,7 @@ describe('real Loader composition', () => { ) }) - const html = server.renderIndex('shell') + const html = server.renderIndex('shell', peer) // Head rows land right after the opening head tag in table order; the body // row lands right after the opening body tag. const order = [ @@ -552,12 +556,14 @@ describe('real Loader composition', () => { // Fresh collection per render: the listener reads live state at emit time. flag = 'light' - expect(server.renderIndex('')).toContain('window.__P__="light"') + expect(server.renderIndex('', peer)).toContain('window.__P__="light"') // Raw taps still run, over the already-rendered rows. const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) - expect(server.renderIndex('')).toContain('window.__Q__=2') + expect(server.renderIndex('', peer)).toContain('window.__Q__=2') untap() + // Every collection is dispatched with the Peer the render speaks for. + expect(renderedFor).toEqual([peer, peer, peer]) // Tag-less fragments: head rows prepend, body rows append, and the // boot-readiness tail lands after the last body row. diff --git a/packages/host/webserver/tsconfig.json b/packages/host/webserver/tsconfig.json index 339809d695..28f025d8fa 100644 --- a/packages/host/webserver/tsconfig.json +++ b/packages/host/webserver/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/schemastery" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 0f544b7144..dd95dd79f1 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -18,8 +18,8 @@ en: 12afbb217bc06678 zh: c07f001ed8bb3300 /deepseek-ai-dsh-client-test-runtime/use-this-package/setting-up-a-feature-spec: - en: 620906bcbaba405a - zh: b7c34920ba5873d1 + en: 411248e12f21d834 + zh: cfb151ce0acbe4e7 /deepseek-ai-dsh-client-test-runtime/use-this-package/local-dom-snapshots: en: 1abbbed6cc4cf57b zh: ab3975178b589653 @@ -30,8 +30,8 @@ en: 1442fc215c8128e8 zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: - en: 2525f42ba8d139f7 - zh: 772661c7358042c8 + en: 707b824ae6391cc0 + zh: bb7621826e6b07db /deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it: en: eb2f9bc90acb7a62 zh: cb59a2134bc83106 @@ -45,8 +45,8 @@ en: 3264640018c1a0e8 zh: d777e0b8ad8b602f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/source-map: - en: e3ef8397426d484b - zh: 23f735f4bda1534a + en: f9d1e576b34bd2d6 + zh: 73bd7c2d57852a9f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/lifecycle: en: b42779f364504719 zh: a10bad7ea1161388 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 162e516a2f..2a02fcb6cf 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -44,7 +44,7 @@ await runtime.dispose() `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. -The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service answers true for every resource, so every access Hook allows every action. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook. +The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service, a `StaticRemoteAccess`, answers true for every resource, so every access Hook allows every action and every `whenAccess` callback runs at once. `TestClientOptions.access` fixes the answer per action instead, through `staticAccessPlugin(allows)`. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook or provides `new StaticRemoteAccess(ctx, allows)`. The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. @@ -93,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin`, or `staticAccessPlugin(options.access)`, in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it @@ -127,7 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | -| [`src/access.ts`](src/access.ts) | `allowAllAccess` props and `allowAllAccessPlugin`, the allow-all stand-in for the remote-access Client row | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props, `StaticRemoteAccess`, `staticAccessPlugin`, and `allowAllAccessPlugin`, the fixed-answer stand-ins for the remote-access Client row | | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 4c86eae6ec..c1721c8790 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -44,7 +44,7 @@ await runtime.dispose() `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 -运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务对每个资源都回答 true,因此每个访问 Hook 都允许每个 action。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook。 +运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务是一个 `StaticRemoteAccess`,对每个资源都回答 true,因此每个访问 Hook 都允许每个 action,每个 `whenAccess` 回调都立即执行。`TestClientOptions.access` 改为按 action 固定答案,经由 `staticAccessPlugin(allows)`。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook,或提供 `new StaticRemoteAccess(ctx, allows)`。 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 @@ -93,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin` 或 `staticAccessPlugin(options.access)`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 @@ -127,7 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | -| [`src/access.ts`](src/access.ts) | `allowAllAccess` props 与 `allowAllAccessPlugin`:remote-access Client 行的全允许替身 | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props、`StaticRemoteAccess`、`staticAccessPlugin` 与 `allowAllAccessPlugin`:remote-access Client 行的固定答案替身 | | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` | diff --git a/packages/test-support/client-runtime/src/access.ts b/packages/test-support/client-runtime/src/access.ts index 4c467ac61f..b2507556b3 100644 --- a/packages/test-support/client-runtime/src/access.ts +++ b/packages/test-support/client-runtime/src/access.ts @@ -1,17 +1,18 @@ /** - * Allow-all access for client specs. Production pages receive the + * Static access for client specs. Production pages receive the * `remoteAccess` Client service and the global-resource `useRemoteAccess` Hook * from the remote-access Client plugin, and the Session and Workspace access * Hooks (Session-scope `useAccess`, `useSessionAccess`, `useWorkspaceAccess`) * from ui-session and ui-workspace over that service. The test runtimes * install an allow-all service and `useRemoteAccess` Hook instead, and * hand-built component props spread {@link allowAllAccess}; a spec of a - * refused path overrides one Hook. + * refused path overrides one Hook or provides a {@link StaticRemoteAccess} + * that refuses the action. * @module @deepseek-ai/dsh-client-test-runtime/src/access */ -import type { Context } from '@deepseek-ai/cordis' -import type { ClientRemoteAccess } from '@deepseek-ai/dsh-api-remote-access/client' +import { Service, type Context } from '@deepseek-ai/cordis' +import type { AccessEffect, ClientRemoteAccess, RemoteEndpoint, TypertResourceKind } from '@deepseek-ai/dsh-api-remote-access/client' import type { GlobalStandardProps, HostObservable, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -31,23 +32,64 @@ export const allowAllAccess: AccessStandardProps = { } const ALLOWED: HostObservable = { getSnapshot: () => true, subscribe: () => () => {} } -const allowed = (): HostObservable => ALLOWED - -/** The `remoteAccess` Client service answering true for every resource. */ -const allowAllRemoteAccess: ClientRemoteAccess = { source: allowed } +const REFUSED: HostObservable = { getSnapshot: () => false, subscribe: () => () => {} } /** - * Plugin standing in for the remote-access Client row: it provides an - * allow-all `remoteAccess` service, so ui-session and ui-workspace publish - * their resource Hooks over it, and the allow-all `useRemoteAccess` Hook on - * the global resource. + * A `remoteAccess` Client service whose answers never change: `allows` + * decides each action on every resource once. `whenAccess` installs an + * allowed action's effects in the caller's fiber at once and never installs a + * refused action's effects. */ -export const allowAllAccessPlugin = { - name: 'client-test-runtime:allow-all-access', - inject: ['slots'], - /** @param ctx - plugin Context with the Slot registry injected. */ - apply(ctx: Context): void { - ctx.provide('remoteAccess', allowAllRemoteAccess) - ctx.slots.provideRoot({ keyedHooks: { remoteAccess: allowed } }) - }, +export class StaticRemoteAccess extends Service implements ClientRemoteAccess { + /** + * @param ctx - Context the service is provided on. + * @param allows - the fixed answer for each action; allows everything when omitted. + */ + constructor(ctx: Context, private readonly allows: (action: string) => boolean = () => true) { + super(ctx, 'remoteAccess') + } + + source(_kind: TypertResourceKind, _id: string, action: string): HostObservable { + return this.allows(action) ? ALLOWED : REFUSED + } + + // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context. + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void { + if (!this.allows(action)) return () => {} + const dispose = this.ctx.effect(callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`) + return () => { void dispose() } + } + + whenResourceAccess(_kind: TypertResourceKind, _id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return this.whenAccess(action, callback) + } } + +/** The plugin shape {@link staticAccessPlugin} returns. */ +export interface StaticAccessPlugin { + readonly name: string + readonly inject: readonly string[] + apply(ctx: Context): void +} + +/** + * Plugin standing in for the remote-access Client row with fixed answers: it + * provides a {@link StaticRemoteAccess}, so ui-session and ui-workspace + * publish their resource Hooks over it, and the matching `useRemoteAccess` + * Hook on the global resource. + * @param allows - the fixed answer for each action on every resource. + * @returns the plugin. + */ +export function staticAccessPlugin(allows: (action: string) => boolean): StaticAccessPlugin { + return { + name: 'client-test-runtime:static-access', + inject: ['slots'], + apply(ctx: Context): void { + new StaticRemoteAccess(ctx, allows) + ctx.slots.provideRoot({ keyedHooks: { remoteAccess: (action: string) => (allows(action) ? ALLOWED : REFUSED) } }) + }, + } +} + +/** {@link staticAccessPlugin} allowing every action on every resource. */ +export const allowAllAccessPlugin: StaticAccessPlugin = staticAccessPlugin(() => true) diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 27fcbfd27b..09a9fc97ce 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -18,7 +18,7 @@ import { act } from '@testing-library/react' import { createInProcessModules, loadPluginModules } from './modules.ts' import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' -import { allowAllAccessPlugin } from '../access.ts' +import { allowAllAccessPlugin, staticAccessPlugin } from '../access.ts' /** The remote-access row: it mounts its own generated Remote contribution and publishes the access Hooks. */ const REMOTE_ACCESS_PACKAGE = '@deepseek-ai/dsh-api-remote-access' @@ -41,6 +41,8 @@ export interface TestClientOptions { readonly awaitConnected?: boolean /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */ readonly connectTimeoutMs?: number + /** The fixed access answer for each action on every resource. Default: every action is allowed. */ + readonly access?: (action: string) => boolean } /** @@ -245,8 +247,10 @@ export class TestClient { release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as Plugin) - // The dropped remote-access row's Hooks: every action is allowed on every resource. - if (generated.includes(REMOTE_ACCESS_PACKAGE)) ctx.plugin(allowAllAccessPlugin) + // The dropped remote-access row's Hooks: every action is allowed on every resource unless the spec fixes the answers. + if (generated.includes(REMOTE_ACCESS_PACKAGE)) { + ctx.plugin(options.access === undefined ? allowAllAccessPlugin : staticAccessPlugin(options.access)) + } await bootClient({ ctx, modules: system, manifest: system.manifest }) if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 40403354af..e3dd760f1a 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -58,7 +58,7 @@ export type { FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, } from './fixtures.ts' export { makeTranslate } from './translate.ts' -export { allowAllAccess, allowAllAccessPlugin } from './access.ts' +export { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess } from './access.ts' export type { AccessStandardProps } from './access.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' diff --git a/packages/test-support/client-runtime/tests/access.client.spec.ts b/packages/test-support/client-runtime/tests/access.client.spec.ts new file mode 100644 index 0000000000..4e4552edf9 --- /dev/null +++ b/packages/test-support/client-runtime/tests/access.client.spec.ts @@ -0,0 +1,82 @@ +/** Fixed-answer access stand-ins: the `remoteAccess` service and the plugin that installs it with the global Hook. */ +import { Context } from '@deepseek-ai/cordis' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' +import type { RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import { describe, expect, it, vi } from 'vitest' +import { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess, staticAccessPlugin } from '../src/access.ts' + +/** An endpoint the access stand-ins answer; the stand-ins read only its string. */ +const GUARDED = 'session/list' as RemoteEndpoint + +describe('StaticRemoteAccess', () => { + it('answers each action once for every resource and installs only an allowed action\'s effects in the caller', async () => { + const root = new Context() + const allows = vi.fn((action: string) => action === GUARDED) + new StaticRemoteAccess(root, allows) + expect(root.remoteAccess.source('session', 's1', GUARDED).getSnapshot()).toBe(true) + expect(root.remoteAccess.source('session', 's1', 'session/fork').getSnapshot()).toBe(false) + // Fixed answers never notify. + for (const action of [GUARDED, 'session/fork']) { + const listener = vi.fn() + root.remoteAccess.source('session', 's1', action).subscribe(listener)() + expect(listener).not.toHaveBeenCalled() + } + + const allowedDispose = vi.fn() + const refused = vi.fn(() => () => {}) + let stopAllowed = (): void => {} + let stopRefused = (): void => {} + const caller = root.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + stopAllowed = ctx.remoteAccess.whenResourceAccess('session', 's1', GUARDED, () => allowedDispose) + stopRefused = ctx.remoteAccess.whenAccess('session/fork', refused) + }, + }) + await caller.await() + expect(refused).not.toHaveBeenCalled() + expect(() => { stopRefused() }).not.toThrow() + stopAllowed() + await vi.waitFor(() => { expect(allowedDispose).toHaveBeenCalledOnce() }) + await caller.dispose() + await root.fiber.dispose() + }) +}) + +describe('allow-all access', () => { + it('allows every action through the props Hooks and a predicate-less service', async () => { + expect([ + allowAllAccess.useRemoteAccess(GUARDED), + allowAllAccess.useAccess(GUARDED), + allowAllAccess.useSessionAccess('s1' as Parameters[0], GUARDED), + allowAllAccess.useWorkspaceAccess('w1' as Parameters[0], GUARDED), + ]).toEqual([true, true, true, true]) + const root = new Context() + new StaticRemoteAccess(root) + expect(root.remoteAccess.source('workspace', 'w1', 'workspace/follow').getSnapshot()).toBe(true) + await root.fiber.dispose() + }) +}) + +describe('staticAccessPlugin', () => { + it('provides the fixed-answer service and the matching global-resource Hook', async () => { + const root = new Context() + let contribution: RootStandardSourceContribution | undefined + root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never) + await root.plugin(staticAccessPlugin(action => action === GUARDED)).await() + const hook = contribution?.keyedHooks?.['remoteAccess'] + expect(hook?.(GUARDED)?.getSnapshot()).toBe(true) + expect(hook?.('session/fork')?.getSnapshot()).toBe(false) + expect(root.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(false) + await root.fiber.dispose() + }) + + it('allows every action in its allow-all form', async () => { + const root = new Context() + let contribution: RootStandardSourceContribution | undefined + root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never) + await root.plugin(allowAllAccessPlugin).await() + expect(contribution?.keyedHooks?.['remoteAccess']?.('session/fork')?.getSnapshot()).toBe(true) + await root.fiber.dispose() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3961a70d0..e81365a42a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4200,6 +4200,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:* version: link:../../test-support/client-runtime @@ -4819,6 +4822,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5092,6 +5098,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5265,6 +5274,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5374,6 +5386,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -11081,6 +11096,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol '@types/compression': specifier: ^1.8.1 version: 1.8.1 From d5d43d931dd2980703c3cb120bafb8d9d5b5c4a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:34:40 +0800 Subject: [PATCH 36/43] =?UTF-8?q?fix(remote-access):=20=E6=8C=89=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E4=BF=AE=E6=AD=A3=E8=AE=BF=E9=97=AE=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=EF=BC=8C=E6=8F=92=E4=BB=B6=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui-entry-customization、voice-input 不再把 pluginNavigation 当必需服务:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 - plugin-manager 每次授权时刷新已加载的列表 - settings-account 账号设置分区与 sign-in 一并按 account/watch 门控,revision 恢复 master 原样 - schedule 会话头部提醒另按会话 useAccess('schedule/list') 渲染 - 导出 staticAccessPlugin;补 whenAccess 归属、撤权与读者页启动测试 - 同步 README、web-server 子系统文档、webserver 包描述与 Agent Note --- .../2026-10-21-when-access.i18n.yaml | 16 +-- .../architecture/2026-10-21-when-access.md | 12 +-- .../architecture/2026-10-21-when-access.zh.md | 12 +-- docs/subsystems/web-server.i18n.yaml | 8 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 2 + .../ui-directory-picker-native/README.zh.md | 2 + .../ui-entry-customization/README.i18n.yaml | 4 +- .../client/ui-entry-customization/README.md | 2 + .../ui-entry-customization/README.zh.md | 2 + .../ui-entry-customization/package.json | 1 + .../src/client/EntryMenuContent.tsx | 18 ++-- .../src/client/index.ts | 18 +++- .../tests/menu.client.spec.tsx | 31 +++++- .../tests/reader-boot.client.spec.ts | 12 +++ .../ui-entry-customization/tsconfig.json | 3 +- .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 2 + .../client/ui-message-feedback/README.zh.md | 2 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../ui-plugin-manager/src/client/index.ts | 2 + .../tests/browser-plugin.client.spec.tsx | 69 +++++++++++- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 + packages/client/ui-schedule/README.zh.md | 2 + .../src/client/ScheduleCatalogAction.tsx | 13 ++- .../schedule-catalog-action.client.spec.tsx | 15 +++ .../tests/ui-session.client.spec.ts | 20 ++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../ui-settings-account/src/client/index.ts | 13 ++- .../tests/access-revocation.client.spec.ts | 101 ++++++++++++++++++ .../tests/apply.client.spec.ts | 6 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../ui-workspace/tests/apply.client.spec.ts | 22 +++- .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 2 + .../client-ui-voice-input/README.zh.md | 2 + .../src/client/VoiceInput.tsx | 13 ++- .../src/client/VoiceSetupDialog.tsx | 13 ++- .../client-ui-voice-input/src/client/mount.ts | 17 ++- .../tests/mount.client.spec.ts | 26 ++++- .../tests/voice-input.client.spec.tsx | 15 ++- packages/host/webserver/package.json | 2 +- .../references/packages.md | 2 +- .../test-support/client-runtime/src/index.ts | 4 +- pnpm-lock.yaml | 3 + 54 files changed, 469 insertions(+), 90 deletions(-) create mode 100644 packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/access-revocation.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml index 6a7ccaae0a..070034fe6f 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -6,8 +6,8 @@ en: 2ef7cfef514c138a zh: 064e3aecbfafcd9c /agent-note-whenaccess-registration-that-follows-access/problem: - en: c12d401db33f7d77 - zh: 3bc2c8ad1a441bd7 + en: 4f88d3ee7868eaa5 + zh: 72a4ce2dde13bef7 /agent-note-whenaccess-registration-that-follows-access/decision: en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 @@ -15,14 +15,14 @@ en: c168de20989cf9a4 zh: bf3b0361beea083d /agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: - en: 0487c2e2f5e71bde - zh: 1750e83e7f9fa1cb + en: 6f30d13790e8792f + zh: fffeaac334b10e6a /agent-note-whenaccess-registration-that-follows-access/alternatives-considered: - en: 670e07d9da3ea599 - zh: 625e8e8bff57ff8c + en: ddc432b693608163 + zh: 620294d8812b0a31 /agent-note-whenaccess-registration-that-follows-access/consequences: - en: 5c7c7c49446fd140 - zh: f8fa106040fac4bc + en: de44fa92c49bc335 + zh: 34436fe971ab013f /agent-note-whenaccess-registration-that-follows-access/related: en: 89073ffbfcff0641 zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md index 66d8705c57..9a9bd3bd21 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.md +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -13,7 +13,7 @@ Client plugins need to switch their own registrations on and off with the curren | Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | | JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | -#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change. +Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change. ## Decision @@ -56,17 +56,17 @@ export interface WhenAccess { | Plugin | Change | Action | |---|---|---| -| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` | -| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` | +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` | | ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | -| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`; the stream's disposer also ends this generation's bonus notices, which a frame of that stream begins, and the notice controller stays outside and begins again under the next grant | `account/watch` | | ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | | ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | | ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | ## Alternatives considered -**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change. +**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change. **Trim the plugin list per connection**: a plugin cannot reload at once when access changes. @@ -78,7 +78,7 @@ export interface WhenAccess { | Consequence | Note | |---|---| -| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged | +| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration | | The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | | Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | | Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md index a4233dcdfe..736433ea95 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -13,7 +13,7 @@ Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏 | 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | | JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | -#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。 +没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。 ## 决策 @@ -56,17 +56,17 @@ export interface WhenAccess { | 插件 | 改法 | action | |---|---|---| -| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` | -| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` | +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` | | ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | -| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`;账号流的 disposer 同时结束本轮奖励提示(提示由该流的帧开始),提示控制器留在外面,下次授权时重新开始 | `account/watch` | | ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | | ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | | ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | ## 考虑过的替代方案 -**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。 +**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。 **按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 @@ -78,7 +78,7 @@ export interface WhenAccess { | 后果 | 说明 | |---|---| -| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 | +| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 | | 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | | 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | | 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 9a9d78794a..bd4c01c442 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md /http-server: - en: c9b2f67d3248c496 - zh: 5fa36d2837ff4f63 + en: 56b268a69964f651 + zh: c8ac8f16bf58a98a /http-server/routes: en: e949c3738a35b58f zh: 138e3aaeb319ddc4 @@ -12,5 +12,5 @@ en: 4b162bd9f280eb06 zh: 3bea98dfee88c582 /http-server/the-service: - en: cc36972b8458279f - zh: c129044935d44769 + en: bbf84a6cd0460dc2 + zh: cc63ce45b0675c6d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 425c0faadc..c6c731343a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje ## The service -`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). +`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d1d469d9e0..21a97be800 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ interface TlsConfig { ## 服务 -`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 +`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 80a96f4d74..d7fe01a1f6 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1e2172c756246031 zh: f33dc7b40b14cceb /deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it: - en: bcba761168d2162c - zh: d484e2305e074c0b + en: f30bb7c6aae98fec + zh: 1d28613c86531119 /deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation: en: f7a287740b4b08f9 zh: 3347375c4e486b22 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index b23d48bbb5..7a77767050 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. +Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access. + ----- diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 57cbad2d5d..0bb78f28d1 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 +访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-entry-customization/README.i18n.yaml b/packages/client/ui-entry-customization/README.i18n.yaml index 3dc39a2b7d..66ea423657 100644 --- a/packages/client/ui-entry-customization/README.i18n.yaml +++ b/packages/client/ui-entry-customization/README.i18n.yaml @@ -15,8 +15,8 @@ en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-entry-customization/use-this-package: - en: 7cc90968634e3f7b - zh: 54f58e17b14f7404 + en: e642513bcfd42d68 + zh: 38b9109ec017900d /deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation: en: 3dcb1f29d03d5afe zh: 5ee39637636cd062 diff --git a/packages/client/ui-entry-customization/README.md b/packages/client/ui-entry-customization/README.md index e8ec30ae03..45ca947c2e 100644 --- a/packages/client/ui-entry-customization/README.md +++ b/packages/client/ui-entry-customization/README.md @@ -34,6 +34,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. +The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it. + ----- diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..3132236ded 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -34,6 +34,8 @@ kind: "package-reference" 在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。 + ----- diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index 51ef4894fb..f98c8ed620 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..22a73babc1 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,8 +1,8 @@ -/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */ +/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */ import { IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import { EntryPickerRow } from './EntryPickerRow.tsx' @@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar' export interface EntryMenuInjected { /** Region of this registration; selects pin copy and the Creator prompt. */ readonly region: EntryRegion + readonly hooks: { + /** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */ + readonly pluginNavigation: HostObservable + } /** Open the Plugins main list. */ readonly browsePlugins: () => void /** Start a Creator draft with this region's prompt without sending it. */ @@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t, + entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t, }: EntryMenuContentProps) { + const browsable = usePluginNavigation(present => present) return <> {entries.map((entry) => { const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`) @@ -39,11 +44,12 @@ export function EntryMenuContent({ onOpen={() => { onDismiss(); onOpen(entry.id) }} onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} /> })} - } separatorBefore={entries.length > 0} + {browsable && } separatorBefore={entries.length > 0} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} - - } onSelect={() => { onDismiss(); createPlugin() }}> + } + } separatorBefore={!browsable && entries.length > 0} + onSelect={() => { onDismiss(); createPlugin() }}> {t('create')} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index d838a25749..ba41d131a9 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -1,5 +1,6 @@ /** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */ import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Services used by the menu rows' footer actions. */ -export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace'] +/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */ +export const inject = ['slots', 'locale', 'uiWorkspace'] /** * Register the same menu rows into each region's More menu. - * @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services. + * @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja })) const t = ctx.locale.bind('entryCustomization') + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'entry-customization: plugin navigation presence') + }) const injected = (region: EntryRegion): EntryMenuInjected => ({ region, + hooks: { pluginNavigation }, browsePlugins: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' }) - ctx.pluginNavigation.openList() + ctx.get('pluginNavigation')?.openList() }, createPlugin: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' }) diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 25560a9f65..1a38e84430 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -34,6 +34,7 @@ function content(overrides: Partial = {}) { const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots) { } -async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) { +async function bench( + language: 'en' | 'zh' = 'en', + { analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {}, +) { const runtime = await SlotTestRuntime.create() runtimes.add(runtime) const openList = vi.fn(), startSession = vi.fn(), track = vi.fn() @@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy locale.setLocale(language) ctx.provide('locale', locale) ctx.slots.installLocale(locale) - ctx.provide('pluginNavigation', { openList } as never) + if (navigation) ctx.provide('pluginNavigation', { openList } as never) ctx.provide('uiWorkspace', { startSession } as never) if (analytics) ctx.provide('productAnalytics', { track } as never) }, @@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy } describe('More menu registrations', () => { + it('shows plugin discovery only while the Plugins page navigation is present', async () => { + const b = await bench('en', { navigation: false }) + const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy() + // The Creator row takes the separator the absent discovery row would carry. + expect(sidebar().getAllByRole('separator')).toHaveLength(1) + const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void } + expect(() => { injected.browsePlugins() }).not.toThrow() + + const navigation = await b.runtime.mount({ + inject: [], + apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) }, + }) + await act(async () => { await Promise.resolve() }) + fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' })) + expect(b.openList).toHaveBeenCalledExactlyOnceWith() + + await navigation.dispose() + await act(async () => { await Promise.resolve() }) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + }) + it('registers one row set per region and removes all of them when the plugin unloads', async () => { const b = await bench() for (const seat of SEATS) { diff --git a/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts new file mode 100644 index 0000000000..539dd64964 --- /dev/null +++ b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts @@ -0,0 +1,12 @@ +// @vitest-environment jsdom +/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */ +import { expect } from 'vitest' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' + +const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' }) + +withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => { + const c = await start() + expect(c.ctx.get('pluginNavigation')).toBeUndefined() + expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization') +}, 60_000) diff --git a/packages/client/ui-entry-customization/tsconfig.json b/packages/client/ui-entry-customization/tsconfig.json index 4933d135c5..e65933f5ea 100644 --- a/packages/client/ui-entry-customization/tsconfig.json +++ b/packages/client/ui-entry-customization/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../../client/ui-workspace" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-slots" } + { "path": "../../client/ui-slots" }, + { "path": "../store" } ] } diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index e171909bad..91205d3552 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -18,8 +18,8 @@ en: e0127a604ff4aebb zh: 38f1b3db30f81b22 /deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures: - en: f060de7d9f44b058 - zh: 60770de36be6e90f + en: e4807e47bca06517 + zh: 649a3f9f62771865 /deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation: en: 61260495346ceb3e zh: 32e05d3eb82fcaf2 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index ef293870b6..feffb193d6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access. + ----- diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index fce7d27049..27a879c2c8 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。 + ----- diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 922ddc8b0f..fef7504d0f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -36,8 +36,8 @@ en: 54d7666c630da471 zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: - en: 1de4fe390871d0da - zh: 1723ae12ccbe3697 + en: fc469bab4fbd6028 + zh: 8f5a04e1b9a46471 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5a56e1e745..62c0aa1568 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -112,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. +Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access. + ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 6b9f54f19e..84d3833b0a 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -112,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 +访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。 + ----- diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 6682477943..2a6a3a48da 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -111,6 +111,8 @@ export function apply(ctx: ClientContext): void { controller.details.reset() if (controller.getSnapshot().status !== 'idle') void controller.load() } + // Host changes made while access was withdrawn reached no subscription below. + refresh() yield ctx.remote.$on('plugin-manager/changed', refresh) yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 3a62e8c5ce..2a6f019ad4 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -21,11 +21,53 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench(allows: (action: string) => boolean = () => true) { +type AccessEffect = (() => void) | Iterable<() => void> + +/** One guarded callback, the Context that owns it, and the disposer of its current installation. */ +interface Guard { + readonly owner: Context + readonly callback: () => AccessEffect + dispose: (() => void) | undefined +} + +/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */ +class SwitchableAccess extends Service { + private allowed = true + private readonly guards: Guard[] = [] + + constructor(serviceCtx: Context) { + super(serviceCtx, 'remoteAccess') + } + + whenAccess(_action: string, callback: () => AccessEffect): () => void { + const guard: Guard = { owner: this.ctx, callback, dispose: undefined } + this.guards.push(guard) + this.install(guard) + return () => {} + } + + /** @param allowed - the answer every guarded effect follows from now on. */ + set(allowed: boolean): void { + this.allowed = allowed + for (const guard of this.guards) { + if (allowed) this.install(guard) + else { guard.dispose?.(); guard.dispose = undefined } + } + } + + private install(guard: Guard): void { + if (!this.allowed || guard.dispose !== undefined) return + const dispose = guard.owner.effect(guard.callback) + guard.dispose = () => { void dispose() } + } +} + +async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() - new StaticRemoteAccess(ctx, allows) + const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined + if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -51,7 +93,7 @@ async function bench(allows: (action: string) => boolean = () => true) { ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal, toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access } } function declare(slots: SlotRegistry): () => void { @@ -157,6 +199,27 @@ describe('ui-plugin-manager browser plugin', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) }) + it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => { + const b = await bench('switchable') + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected: object = b.slots.entries('main')[0]!.inject!() + const face = injected as PluginManagerFace + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(false) + expect(b.slots.entries('main')).toEqual([]) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(true) + expect(b.slots.entries('main')).toHaveLength(1) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + }) + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { const allows = vi.fn(() => false) const b = await bench(allows) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index d3f7274631..f21ddb5997 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4c0dca55e4e9866d zh: 9ee6b565c7a81353 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: - en: 6bd6882dbbbefcb0 - zh: 03aa15f1a65d13dc + en: 47cd16c142e82385 + zh: 181849a3c7a167fd /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: en: 959145f9a08c3913 zh: caae642a82d2acd3 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index bfcb80179c..da8d202d0d 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -57,6 +57,8 @@ The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchi Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy. +Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b201a5ca5d..057e42a2ac 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -57,6 +57,8 @@ kind: "package-reference" 时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。 +访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index abeba05542..9aa03b8f81 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -31,8 +31,17 @@ export type ScheduleCatalogActionProps = const SECOND_MS = 1_000 const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } -/** Current-Session reminder catalog with durable deletion. */ -export function ScheduleCatalogAction({ +/** + * Current-Session reminder catalog with durable deletion, rendered only while + * the current Peer may list this Session's reminders. + * @param props - the Session seats, the injected catalog and verbs, and localized copy. + * @returns the reminder catalog action, or nothing without access. + */ +export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) { + return props.useAccess('schedule/list') ? : null +} + +function SessionScheduleCatalog({ useSession, useCatalog, onDelete, onRetry, openTaskDetail, t, }: ScheduleCatalogActionProps) { const openState = useSession(snapshot => snapshot.openState) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 9aadcc8bfb..5e0a6e1670 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -108,6 +108,7 @@ function props( records: readonly ScheduleRecord[] | undefined, openState: SessionSnapshot['openState'] = 'open', dictionary: typeof zh | typeof en = en, + allowed = true, ): ScheduleCatalogActionProps { const snapshot = sessionSnapshot(openState) const useSession = (select: (value: SessionSnapshot) => T): T => select(snapshot) @@ -120,6 +121,7 @@ function props( return { sessionId: SESSION, useSession, + useAccess: vi.fn((_action: string) => allowed), useCatalog, onDelete: vi.fn(async () => {}), onRetry: vi.fn(async () => {}), @@ -140,6 +142,19 @@ function metadataLines(): Element[] { } describe('ScheduleCatalogAction visibility', () => { + it('renders nothing and reads no catalog while the current Peer may not list this Session\'s reminders', () => { + const refused = props([record('active', 'after', START + 60_000)], 'open', en, false) + let catalogReads = 0 + const useCatalog: ScheduleCatalogActionProps['useCatalog'] = (select) => { + catalogReads++ + return refused.useCatalog(select) + } + const view = render() + expect(view.container.innerHTML).toBe('') + expect(refused.useAccess).toHaveBeenCalledWith('schedule/list') + expect(catalogReads).toBe(0) + }) + it('hides the entry once a successful read proves there are no active reminders', () => { const active = [record('active', 'after', START + 60_000)] const view = render() diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 776db63bed..c12901a0d8 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -768,6 +769,25 @@ describe('ui-session apply', () => { expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded) }) + it('hangs Session-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + ctx.provide('sessions', bench.sessions) + ctx.provide('slots', { provideRoot: vi.fn(), installScope: vi.fn(), bindStoreScope: vi.fn() } as never) + new StaticRemoteAccess(ctx) + apply(ctx) + const dispose = vi.fn() + const caller = ctx.plugin({ + inject: ['uiSession'], + apply: (callerCtx: Context) => { callerCtx.uiSession.whenAccess(sessionId('s1'), 'session/fork', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + it('keeps the Host loader half inert', () => { expect(() => { nodeApply() }).not.toThrow() }) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 1612f7825a..4a3303fc7e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -18,8 +18,8 @@ en: 45aa7b44d5424d9b zh: 20c7d344c4e9bce0 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: - en: c1c4db71fd09ca13 - zh: a40c0a01e9951945 + en: 3e5371e2bf7bd577 + zh: 91b1586963d9df58 /deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation: en: 186b2046cfb5ef76 zh: 57b042a618fb612e diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2b02a9d990..47e540a394 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -63,6 +63,8 @@ The Desktop preload marker enables the introduction after account credentials ar The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task. +Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 7485d31445..b03a324e34 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。 +访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index db0901ee9b..65a989b700 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -116,13 +116,16 @@ export function apply(ctx: Context): void { void request.finally(() => { if (refreshing === request) refreshing = undefined }) return request } + ctx.effect(() => () => { revision++ }, 'account: details request lifetime') // The account stream, and the reads it starts, follow access to this host's account state. ctx.remoteAccess.whenAccess('account/watch', function* () { - yield () => { revision++ } const stream = ctx.remote.$stream({ name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), }) let disposed = false + // `notices.end()` disposes this generation's notice lifetime, which a frame + // of this stream begins; the notice controller stays outside and begins + // again under the next grant. yield () => { disposed = true; notices.end(); return stream.dispose() } void (async () => { for await (const frame of stream) { @@ -291,9 +294,9 @@ export function apply(ctx: Context): void { }), }, DesktopOnboardingEntry)) } - ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, - }, AccountOnboarding)) + }, AccountOnboarding))) ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ name: 'shell.quota-notice', locale: 'settings.account', select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, @@ -314,7 +317,7 @@ export function apply(ctx: Context): void { ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, }, AccountMenu))) - ctx.slots.inject('settings.section', () => { + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.section', () => { let unregister: (() => void) | undefined const update = () => { if (snapshot.view?.status === 'credential-stored') { @@ -330,5 +333,5 @@ export function apply(ctx: Context): void { listeners.add(update) update() return () => { listeners.delete(update); unregister?.() } - }) + })) } diff --git a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts new file mode 100644 index 0000000000..90735f78fd --- /dev/null +++ b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply } from '../src/client/index.ts' + +afterEach(() => { vi.unstubAllEnvs() }) + +type AccessEffect = (() => void) | Iterable<() => void> + +/** One `whenAccess` controller: its callback and the disposers of its current installation. */ +interface Guard { + readonly action: string + readonly install: () => AccessEffect + installed: (() => void)[] | undefined +} + +it('withdraws every account surface and the account stream on revocation, and restores them on the next grant', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { status: 'credential-stored', links, attempt: null } + const guards: Guard[] = [] + const grant = (): void => { + for (const guard of guards) { + const effect = guard.install() + guard.installed = typeof effect === 'function' ? [effect] : [...effect] + } + } + const revoke = (): void => { + for (const guard of [...guards].reverse()) { + for (const dispose of (guard.installed ?? []).reverse()) dispose() + guard.installed = undefined + } + } + const registered = new Map() + const streams: { readonly signal: AbortSignal; readonly accepted: Promise }[] = [] + const lifetime: (() => void)[] = [] + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, + configForms: { + get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), + describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), + }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + return () => { dispose?.() } + }, + register: (entry: { readonly name: string }) => { + registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1) + return () => { registered.set(entry.name, registered.get(entry.name)! - 1) } + }, + }, + remoteAccess: { + whenAccess: (action: string, install: () => AccessEffect) => { + guards.push({ action, install, installed: undefined }) + return () => {} + }, + }, + remote: { + $on: () => () => {}, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => { + const abort = new AbortController() + const accepted = Promise.withResolvers() + streams.push({ signal: abort.signal, accepted: accepted.promise }) + return { + signal: abort.signal, + dispose: () => { abort.abort() }, + async *[Symbol.asyncIterator]() { + yield { value: signedIn, accept: () => { accepted.resolve(undefined) } } + await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) }) + }, + } + }, + }, + } + const surfaces = (): Record => Object.fromEntries( + ['settings.launcher', 'shell.quota-notice', 'settings.models.sign-in', 'settings.section'].map(name => [name, registered.get(name)]), + ) + try { + apply(ctx as never) + expect(new Set(guards.map(guard => guard.action))).toEqual(new Set(['account/watch'])) + grant() + await streams[0]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + + revoke() + expect(streams[0]!.signal.aborted).toBe(true) + expect(surfaces()).toEqual({ 'settings.launcher': 0, 'shell.quota-notice': 0, 'settings.models.sign-in': 0, 'settings.section': 0 }) + + grant() + await streams[1]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + } finally { + revoke() + for (const dispose of lifetime.reverse()) dispose() + } +}) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 5eb29a1ade..c944e6ce3f 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -78,13 +78,13 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac } }, 60_000) -withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => { +withoutAccountAccess('opens no account stream and registers no account surface while the current Peer may not watch the account', async ({ start }) => { const c = await start() expect(c.mock.streams.end('account/watch')).toBe(0) expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) expect(quotaNoticeEntry(c)).toBeUndefined() - // The models sign-in entry lives inside the settings shell, which follows its own access. - expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) + expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) }, 60_000) it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 85030a3d13..377d9797cd 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: 27f0f6ec55f00228 - zh: 69b47ff1efd81c17 + en: ba55fe92bf49dc5a + zh: 9a0e1345496a0ae6 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 1531b45780d0689f zh: 844548208c08a9c9 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 1bfd4e9aab..0b14da3bcb 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -59,6 +59,8 @@ On a loopback browser, the shell renders **Open configuration file** only when t The onboarding ledger projects in ascending order and mounts exactly one step at a time. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source. Visible steps own their dialog chrome and app-root `inert` lifecycle. +Access: the settings shell and the `settings.open` shortcut are registered only while the current Peer may call `settings/describe`. The owner always has access. + ----- diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 932dc56e46..d1e276b141 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。 +访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 1723a4decd..bf5acfea6b 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { HostObservable, PropsRenderSlots, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, StaticRemoteAccess, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -56,7 +56,7 @@ const workspaceState = ( const ALLOWED: HostObservable = createSnapshotStore(true) -async function bench() { +async function bench(access: 'fake' | 'static' = 'fake') { const ctx = new Context() const commands = new Map() ctx.provide('shortcuts', { @@ -71,7 +71,8 @@ async function bench() { const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) const stopGuard = vi.fn() const whenResourceAccess = vi.fn((): () => void => stopGuard) - ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) + if (access === 'static') new StaticRemoteAccess(ctx) + else ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -241,6 +242,21 @@ describe('ui-workspace apply', () => { expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded) }) + it('hangs Workspace-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const b = await bench('static') + onTestFinished(() => b.ctx.fiber.dispose()) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const dispose = vi.fn() + const caller = b.ctx.plugin({ + inject: ['uiWorkspace'], + apply: (callerCtx: Context) => { callerCtx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() + }) + it('reports a default Workspace creation failure through the shared notice overlay', async () => { const b = await bench() onTestFinished(() => b.ctx.fiber.dispose()) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index c906ffc9c7..6aa37a6ef0 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package: - en: 4db1c67430cfa691 - zh: 484808eeff0d7692 + en: b7a99b656839e8a0 + zh: 5c6e2635eb20fc11 /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: 8fe96eeef9afe7d7 zh: 11936f647ded39eb diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 3eba1c3825..2aa4fdd707 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -31,6 +31,8 @@ Use the native **Input device** selector in bundle details to follow the system Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready. +`pluginNavigation` is optional: the plugin starts without it, and the setup dialog offers the details button only while the service is present. + ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index b7e9164a02..3dadc0d92d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 +`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。 + ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 7b60ca9b7e..a396f1d7ab 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -31,7 +31,12 @@ export interface VoiceInputActions { /** Entry-injected Host readiness and microphone operations. */ export interface VoiceInputInjected extends VoiceInputActions { - hooks: { speechReadiness: HostObservable; microphoneDevice: HostObservable } + hooks: { + speechReadiness: HostObservable + microphoneDevice: HostObservable + /** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */ + pluginNavigation: HostObservable + } } /** Composer-owned expansion and draft actions; preferences stay in plugin settings. */ @@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise { /** Render a compact microphone or an expanded capture, transcription, or retry row. */ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, - createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) { + createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog + const navigable = usePluginNavigation(present => present) const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId) const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby' || provider?.preparation.phase === 'waking') @@ -153,7 +159,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + onDismiss={() => { setSetupOpen(false) }} + onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} /> return
- + {onOpenDetails !== undefined && } }>

{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}

diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index d79d281def..bfe499f91b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx' import { Recording } from './audio.ts' import { createMicrophoneDeviceStore } from './microphone-device.ts' @@ -15,7 +16,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] +export const inject = ['remote', 'slots', 'locale'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en, ja })) @@ -24,9 +25,17 @@ function registerUi(ctx: Context): void { const readiness = observeReadiness(ctx) ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'voice-input: plugin navigation presence') + }) const actions: VoiceInputInjected = { - openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, - hooks: { speechReadiness: readiness.state, microphoneDevice: microphone }, + openSettings: () => { ctx.get('pluginNavigation')?.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, + hooks: { speechReadiness: readiness.state, microphoneDevice: microphone, pluginNavigation }, selectMicrophone: (device) => { microphone.set(device) }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id) @@ -62,7 +71,7 @@ function registerUi(ctx: Context): void { */ export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi) try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error } return async () => { await ui.dispose(); await disposeRemote() } } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 3287f92a1f..a328473da6 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -34,7 +34,7 @@ function assertVoiceActions(value: Record): asserts value is Re assert(typeof value.hooks === 'object' && value.hooks !== null) } -async function fixture(fail = false) { +async function fixture(fail = false, navigation = true) { const ctx = new Context(), unmount = vi.fn(async () => {}) class Remote extends Service { constructor() { super(ctx, 'remote') } @@ -45,7 +45,7 @@ async function fixture(fail = false) { } new Remote() const openBundle = vi.fn() - ctx.provide('pluginNavigation', { openBundle }) + if (navigation) ctx.provide('pluginNavigation', { openBundle }) const configure = vi.fn(async () => ({ ok: true, value: {} })) const prepare = vi.fn(async () => ({ ok: true, value: {} })) const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} })) @@ -63,6 +63,28 @@ async function fixture(fail = false) { return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } +it('starts without the Plugins page navigation and follows its presence', async () => { + hostApply() + const b = await fixture(false, false) + try { + await b.ctx.plugin({ inject: [...inject], apply: ctx => mountVoiceInput(ctx, REMOTE) }) + const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput) + const actions = entry!.inject!() + assertVoiceActions(actions) + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + expect(() => { actions.openSettings() }).not.toThrow() + const navigation = b.ctx.plugin({ apply: (ctx: Context) => { ctx.provide('pluginNavigation', { openBundle: b.openBundle } as never) } }) + await navigation + await vi.waitFor(() => { expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(true) }) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') + await navigation.dispose() + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + } finally { + await b.ctx.fiber.dispose() + } +}) + it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { hostApply() const b = await fixture() diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index 2dd0f150cf..b0e23ff464 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -18,7 +18,7 @@ beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.st afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) const id = 'sensevoice-local' as SpeechProviderId const transcript: Transcript = { text: '检查 TypeScript 类型', audioSeconds: 2, inferenceSeconds: 0.4 } -function fixture(recording?: Recording) { +function fixture(recording?: Recording, navigable = true) { const capture = Object.assign(new Recording(() => {}), { start: vi.fn(async () => {}), stop: vi.fn(async () => new Uint8Array(48)), amplitude: () => 0, dispose: vi.fn(async () => {}) }) const inputActions = { notify: vi.fn(), insertQuote: () => false, @@ -35,6 +35,7 @@ function fixture(recording?: Recording) { openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, + usePluginNavigation: bindSnapshotSelector(createSnapshotStore(navigable)), t: makeTranslate(zh, commonZh) } const view = render() return { props, capture, inputActions, readiness, transcribe, view } @@ -335,6 +336,18 @@ it.each([ expect(b.props.openSettings).toHaveBeenCalledOnce() }) +it('offers no plugin details from setup guidance while the Plugins page navigation is absent', () => { + const b = fixture(undefined, false), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.queryByRole('button', { name: zh['setupPrompt.open'] })).toBeNull() + expect(dialog.getByRole('button', { name: zh['setupPrompt.later'] })).toBeTruthy() + expect(b.props.openSettings).not.toHaveBeenCalled() +}) + it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => { const b = fixture() render(