From 931505a9cc4a6ab6e877f5a85a2dd05437e01da5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 18 Sep 2026 04:09:03 +0800 Subject: [PATCH 01/41] fix(workspace-files): list directory links through their resolved target Windows directory junctions are reparse points that lstat reports as symlinks, so list refused My Music, My Pictures and My Videos with workspace-file/not-directory while its own listing reported them as directories. list now follows a final link, requires the resolved target to be a directory inside the workspace root, and keeps read's no-follow gate. --- ...26-09-05-workspace-files-service.i18n.yaml | 4 +-- .../2026-09-05-workspace-files-service.md | 8 ++--- .../2026-09-05-workspace-files-service.zh.md | 8 ++--- ...ndows-directory-junction-listing.i18n.yaml | 6 ++++ ...9-18-windows-directory-junction-listing.md | 27 +++++++++++++++++ ...8-windows-directory-junction-listing.zh.md | 27 +++++++++++++++++ packages/api/workspace-files/README.i18n.yaml | 4 +-- packages/api/workspace-files/README.md | 2 +- packages/api/workspace-files/README.zh.md | 2 +- packages/api/workspace-files/src/index.ts | 11 ++++++- .../api/workspace-files/tests/list.spec.ts | 29 ++++++++++++++++--- 11 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..8b3394223d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: b8301a423c0c9cf69a177f079de38dff0b112cd4 +2026-09-05-workspace-files-service.zh.md: c01324db5aa3dc0112604f297dcc4782d42347a4 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..b8301a423c 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -43,7 +43,7 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. - **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. -- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. +- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`. A child the listing reports as a directory opens through its resolved target, including a Windows junction, and the listing then names that target; [directory link listing](../bug-fix/2026-09-18-windows-directory-junction-listing.md) owns the rule. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire @@ -56,12 +56,12 @@ Two path vocabularies leave the service, and each method uses exactly one. `read `read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: -1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. +1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods. `list` instead follows a final link and judges the resolved target: the target must stat as a directory, and a link to a file or a link whose target is gone is `not-directory` (kind `symlink`). An empty path is a `gateway/bad-request`. 2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. 3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`. 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. -After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position. +After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position; a final link resolves and passes containment before its target kind is read. ### Failures @@ -142,7 +142,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, ## Testing -Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, directory links, links whose target is gone, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..c01324db5a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -43,7 +43,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 - **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 -- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 +- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`。列表报告为目录的子项经其解析目标打开,Windows 目录联接亦然,此时列表以该目标命名;[目录链接列举](../bug-fix/2026-09-18-windows-directory-junction-listing.zh.md)拥有该规则。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 @@ -56,12 +56,12 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: -1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 +1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`)。`list` 改为跟随末端链接并判定解析后的目标:目标经 stat 必须为目录,指向文件的链接或目标已消失的链接为 `not-directory`(kind 为 `symlink`)。空路径是 `gateway/bad-request`。 2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 -路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。 +路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。 ### 失败 @@ -142,7 +142,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、目录链接、目标已消失的链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml new file mode 100644 index 0000000000..fb7a54a51e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md +2026-09-18-windows-directory-junction-listing.md: 8e0edc0b997c1b5526395de97d380daa7bc86e48 +2026-09-18-windows-directory-junction-listing.zh.md: 3236c063d667add26f400506c775424f095b699a diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md new file mode 100644 index 0000000000..8e0edc0b99 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md @@ -0,0 +1,27 @@ +# Agent Note: List directory links in the workspace file tree + +Status: implemented + +English | [中文](2026-09-18-windows-directory-junction-listing.zh.md) + +## Problem + +Windows profile directories such as `Documents\My Music` are directory junctions: reparse points that `lstat` reports as symlinks rather than directories. The `list` endpoint gated on `lstat` before resolving, so it refused every final link with `workspace-file/not-directory`, while its own listing reported those same children as `directory` because `listDir` resolves each child. The file tree therefore listed the junctions and refused to open them, and the Sidebar reported `My Music`, `My Pictures`, and `My Videos` as not a directory. + +## Decision + +`list` follows a final link and judges what it resolves to. The resolved target must pass workspace containment and must stat as a directory, so a directory link inside the workspace lists through its target, and the returned workspace path names that target. A link resolving outside the workspace is `workspace-file/outside-workspace`; a link to a non-directory, or one whose target is gone, is `workspace-file/not-directory` with kind `symlink`. + +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` keep their `lstat` gate that refuses a final symlink, so no read path follows a link. The [Workspace Files service](../architecture/2026-09-05-workspace-files-service.md) keeps ownership of the service; its earlier rule that `list` rejects a final symlink is superseded here. + +## Alternatives considered + +**Drop the `lstat` gate for every method.** Rejected: reading through a link is a separate exposure decision, and the file methods' no-follow rule is a trust rule the report asked to keep. + +**Special-case Windows junctions.** Rejected: `fs-local` already reports a junction as a `symlink` path entry, and the resolve-then-contain rule is identical for a junction and a POSIX directory symlink. + +**Keep the gate and report a linked directory from the parent listing only.** Rejected: the listing already reports the resolved type, so the endpoint contradicted itself and a reader could not open what the parent presented as a directory. + +## Consequences + +A directory link inside the workspace now lists like its target, and the Windows profile junctions expand in the Sidebar. A link resolving outside the workspace reports `outside-workspace` instead of `not-directory`, which names the actual reason. Read access is unchanged. Tests cover a directory link inside the workspace, a link resolving outside it, and a link whose target is deleted; the spec creates a junction with `symlink(target, path, 'junction')` on Windows and a directory symlink elsewhere, so one spec runs on both. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md new file mode 100644 index 0000000000..3236c063d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 列举工作区文件树中的目录链接 + +Status: implemented + +[English](2026-09-18-windows-directory-junction-listing.md) | 中文 + +## Problem + +Windows 的个人目录如 `Documents\My Music` 是目录联接:`lstat` 把这类重解析点报告为符号链接而不是目录。`list` 端点在解析之前先用 `lstat` 把关,于是把所有末端链接都以 `workspace-file/not-directory` 拒绝;而它自己的列举又把同样的子项报告为 `directory`,因为 `listDir` 会解析每个子项。文件树因此既列出这些联接、又拒绝打开它们,侧栏把 `My Music`、`My Pictures`、`My Videos` 报告为非目录。 + +## Decision + +`list` 跟随末端链接并判定其解析结果。解析后的目标必须通过工作区包含检查,且经 stat 必须为目录,因此工作区内的目录链接经其目标列举,返回的工作区路径也以该目标命名。解析到工作区外的链接为 `workspace-file/outside-workspace`;指向非目录的链接、或目标已消失的链接,为 kind 为 `symlink` 的 `workspace-file/not-directory`。 + +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 保留拒绝末端符号链接的 `lstat` 把关,因此没有任何读取路径跟随链接。[工作区文件服务](../architecture/2026-09-05-workspace-files-service.zh.md)继续拥有该服务;它此前「`list` 拒绝末端符号链接」的规则由本记录取代。 + +## Alternatives considered + +**对所有方法去掉 `lstat` 把关。** 被否:经链接读取是另一项暴露决策,文件方法的「不跟随」规则是报告方要求保留的信任规则。 + +**单独处理 Windows 目录联接。** 被否:`fs-local` 已把目录联接报告为 `symlink` 路径项,且「先解析再判包含」的规则对目录联接与 POSIX 目录符号链接完全相同。 + +**保留把关,只靠父层列举报告被链接的目录。** 被否:列举本来就报告解析后的类型,端点因此自相矛盾,读取方无法打开父层呈现为目录的条目。 + +## Consequences + +工作区内的目录链接现在与其目标一样可列举,Windows 个人目录中的联接可在侧栏展开。解析到工作区外的链接报告 `outside-workspace` 而不是 `not-directory`,如实说明原因。读取权限不变。测试覆盖工作区内的目录链接、解析到工作区外的链接,以及目标被删除的链接;spec 在 Windows 上用 `symlink(target, path, 'junction')` 创建目录联接,在其他平台创建目录符号链接,因此同一 spec 两端都跑。 diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 4bbf6a7a48..c43627f94d 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: f7e40138d8b790f993e3cc8a15d4216ca966cabb -README.zh.md: 04548bed7ffc1698e9a05ffbc93157ea07d6f932 +README.md: b4490d385f3a34251920534bb57e6d84deeda729 +README.zh.md: 1c11e14b1a5bec54d446f367474e14720a3a478f diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f7e40138d8..b4490d385f 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -51,7 +51,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and ### File-read and directory checks -Every operation first uses `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. `list` alone requires the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`. +Every operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires what it resolves to be a directory inside the workspace root, so a linked directory lists like its target. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`. ### The change feed diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 04548bed7f..1c11e14b1a 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 文件读取与目录检查 -每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 +每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接(任何平台上的目录符号链接,包括 Windows 目录联接),要求解析结果是工作区内的目录,因此被链接的目录与其目标一样可列举。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 ### 变更流 diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 64e91c7fbf..2c4f6f31b1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -335,7 +335,10 @@ export class WorkspaceFiles extends TypertRemoteService { @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal) - if (entry.type !== 'directory') { + // A final link — a Windows junction or a symlink — is listed through the + // directory it resolves to, matching the child type `listDir` reports for + // that entry; `read` keeps its own no-follow gate on the final component. + if (entry.type !== 'directory' && entry.type !== 'symlink') { throw new RemoteError( 'workspace-file/not-directory', `"${path}" is a ${entry.type}`, @@ -343,6 +346,12 @@ export class WorkspaceFiles extends TypertRemoteService { ) } const target = await this.confine(root, workspaceRoot, path, signal) + if (entry.type === 'symlink') { + const info = await this.ctx.fs.stat(target, signal) + if (info?.type !== 'directory') { + throw new RemoteError('workspace-file/not-directory', `"${path}" does not resolve to a directory`, { path, kind: 'symlink' }) + } + } const children = await this.ctx.fs.listDir(target, signal) return { path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)), diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts index 6cadad6ce3..e2e603dfe4 100644 --- a/packages/api/workspace-files/tests/list.spec.ts +++ b/packages/api/workspace-files/tests/list.spec.ts @@ -1,6 +1,6 @@ /** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' @@ -20,6 +20,10 @@ afterEach(async () => { const endpoint = (caps?: { maxEntries?: number }): ReturnType => harness.endpoint(caps) +/** Create a link to a directory: a Windows junction where junctions exist, a directory symlink elsewhere. */ +const directoryLink = (target: string, link: string): Promise => + symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir') + describe('workspaceFiles.list — the happy path', () => { it('lists the root as the empty workspace path, with types and file sizes', async () => { await mkdir(join(workspace, 'src')) @@ -64,6 +68,15 @@ describe('workspaceFiles.list — the happy path', () => { { name: 'to-file', type: 'file', size: 1 }, ]) }) + + it('lists a directory link whose target is inside the workspace', async () => { + await mkdir(join(workspace, 'real-dir')) + await writeFile(join(workspace, 'real-dir', 'child.txt'), 'x', 'utf8') + await directoryLink(join(workspace, 'real-dir'), join(workspace, 'dir-link')) + const listing = await endpoint().list(harness.scope, 'dir-link', signal()) + expect(listing.path).toBe('real-dir') + expect(listing.entries).toEqual([{ name: 'child.txt', type: 'file', size: 1 }]) + }) }) describe('workspaceFiles.list — the entry cap', () => { @@ -93,11 +106,19 @@ describe('workspaceFiles.list — gates', () => { expect(failure.code).toBe('workspace-file/outside-workspace') }) - it('rejects a symlinked directory before following it, wherever it points', async () => { - await symlink(outside, join(workspace, 'escape')) + it('rejects a directory link that resolves outside the workspace', async () => { + await directoryLink(outside, join(workspace, 'escape')) const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal())) + expect(failure.code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a directory link whose target is gone', async () => { + await mkdir(join(workspace, 'vanishing')) + await directoryLink(join(workspace, 'vanishing'), join(workspace, 'dangling')) + await rm(join(workspace, 'vanishing'), { recursive: true }) + const failure = await failureOf(endpoint().list(harness.scope, 'dangling', signal())) expect(failure.code).toBe('workspace-file/not-directory') - expect(failure.details).toMatchObject({ kind: 'symlink' }) + expect(failure.details).toMatchObject({ path: 'dangling', kind: 'symlink' }) }) it('rejects a file, which has no children to list', async () => { From 26b49803645c2dc2ef7620d817fdc212983ba7c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 23 Sep 2026 14:15:26 +0800 Subject: [PATCH 02/41] feat(voice-input): guide unready microphones to plugin setup --- apps/web/tests/voice-input.e2e.ts | 40 ++++++++++- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 14 ++-- .../ui-plugin-manager/src/client/index.ts | 59 ++++++++++----- .../src/client/navigation-store.ts | 25 +++++++ .../tests/browser-plugin.client.spec.tsx | 13 +++- .../tests/components.client.spec.tsx | 14 ++++ .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 4 +- .../client-ui-voice-input/README.zh.md | 4 +- .../src/client/VoiceInput.tsx | 26 ++++--- .../src/client/VoiceSetupPrompt.tsx | 27 +++++-- .../src/client/locales.ts | 8 ++- .../client-ui-voice-input/src/client/mount.ts | 5 +- .../tests/mount.client.spec.ts | 7 +- .../tests/preparation.client.spec.tsx | 2 +- .../tests/voice-input.client.spec.tsx | 71 ++++++++++++++++--- scripts/gen-cordis-catalog.ts | 1 + snapshots/web/voice-input/setup.expected.md | 6 ++ .../web/voice-input/unavailable.expected.md | 6 ++ 22 files changed, 274 insertions(+), 70 deletions(-) create mode 100644 packages/client/ui-plugin-manager/src/client/navigation-store.ts create mode 100644 snapshots/web/voice-input/setup.expected.md create mode 100644 snapshots/web/voice-input/unavailable.expected.md diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index bbb270a1f8..545cd9ba11 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser } from 'playwright' import { expect, it, onTestFinished, vi } from 'vitest' -import type { SpeechInput, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import type { SpeechInput, SpeechPreparationState, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import type {} from '@deepseek-ai/dsh-experimental-speech-to-text' import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, webSnapshotMode, watchConsole, @@ -19,7 +19,7 @@ const interruptedExpected = fileURLToPath(new URL('../../../snapshots/web/voice- const recordingExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/recording.expected.md', import.meta.url)) const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url)) -it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submits only the reviewed transcript through Session replay', async () => { +it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from standby and submits only the reviewed transcript through Session replay', async () => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-voice-browser-')) const resources: { scaffold?: WebScaffold; browser?: Browser } = {} onTestFinished(async () => { @@ -40,11 +40,14 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi expect(input.audio.byteLength).toBeGreaterThan(44) return { text: prompt.slice(prefix.length), audioSeconds: 1, inferenceSeconds: 0.1 } }) + let preparation: SpeechPreparationState = { phase: 'unprepared' } + const readinessListeners = new Set<() => void>() await scaffold.ctx.plugin({ inject: ['speechToText'], apply(ctx) { ctx.effect(() => ctx.speechToText.register({ info: { id: 'sensevoice-local' as SpeechProviderId, name: 'Recorded recognizer', location: 'host-local', languages: ['auto'] }, preparation: { - snapshot: () => ({ phase: 'standby' }), subscribe: () => () => {}, + snapshot: () => preparation, + subscribe: (listener) => { readinessListeners.add(listener); return () => { readinessListeners.delete(listener) } }, prepare: () => { throw new Error('Cached resources must allow recording without preparation') }, cancel: async () => {}, }, @@ -68,6 +71,37 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi await page.goto(scaffold.authenticatedUrl) await connectFreshWorkspace(page, scaffold.workspaceCwd) const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true }) + const setupMic = page.getByRole('button', { name: 'Open voice plugin settings', exact: true }) + await setupMic.click() + const setup = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true }) + await setup.waitFor() + await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/setup.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + await setup.getByRole('button', { name: 'Later', exact: true }).click() + expect(await setup.count()).toBe(0) + expect(recognize).not.toHaveBeenCalled() + preparation = { phase: 'failed', message: 'Model preparation failed' } + for (const listener of readinessListeners) listener() + await setupMic.click() + const unavailable = page.getByRole('dialog', { name: 'Speech recognition is not ready', exact: true }) + await unavailable.waitFor() + await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/unavailable.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + await page.setViewportSize({ width: 420, height: 900 }) + const dialogBox = await unavailable.boundingBox() + expect(dialogBox!.x).toBeGreaterThanOrEqual(0) + expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(420) + await page.keyboard.press('Escape') + await unavailable.waitFor({ state: 'hidden' }) + await setupMic.click() + await unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true }).click() + await page.setViewportSize({ width: 1280, height: 900 }) + await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-voice-input-bundle"]') + .getByRole('button', { name: 'Retry preparation', exact: true }).waitFor() + expect(await unavailable.count()).toBe(0) + preparation = { phase: 'standby' } + for (const listener of readinessListeners) listener() + await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click() await mic.waitFor() const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox() const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 568cf01093..fa8a85d9fc 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: d89e56a1115d077865c49150ecbf4991a524cfe9 -README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5 +README.md: 7334834eb83ef3d257a7d1239ce6cdb14e53b201 +README.zh.md: 96197d54ae5388ebb49531e54b090cb397b60695 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d89e56a111..7334834eb8 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -101,7 +101,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Beta marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the cards, a bundle, an official plugin, or a bundle row; leaving the page resets it to the list. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Beta marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 50699e4749..96197d54ae 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -101,7 +101,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示 Beta 标记。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择卡片、某个组合包、某个官方插件或组合包的某一行;离开页面时重置为列表。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示 Beta 标记。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index bcc9ff0d40..fb5e1cc063 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,7 +21,8 @@ import { StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { createNavigationStore } from './navigation-store.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import type { PluginManagerLocaleKey } from './locales.ts' import { @@ -43,18 +44,12 @@ export type PluginManagerPageProps = | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > & InjectFace + & PropsStore> /** The page's slot renderer, narrowed to the configuration slots. */ type RenderConfig = PluginManagerPageProps['renderSlot'] type ResolveText = PluginManagerFace['resolveText'] -/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */ -type View = - | { readonly kind: 'list' } - | { readonly kind: 'package'; readonly name: string } - | { readonly kind: 'item'; readonly id: string } - | { readonly kind: 'row'; readonly name: string; readonly rowId: string } - type RowPhase = NonNullable /** How long the list marks a package an install just enabled. */ @@ -1146,9 +1141,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const state = props.usePluginManager(snapshot => snapshot) const ledger = props.useConfigLedger(snapshot => snapshot) // What is open; a package that leaves the list (uninstalled) drops back to the cards. - const [view, setView] = useState({ kind: 'list' }) + const view = props.useStore(state => state.view), { setView } = props.actions const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) + useEffect(() => () => { setView({ kind: 'list' }) }, [setView]) // A package an install just enabled: scroll it into view and mark it for a moment. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } useEffect(() => { diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 476e3dc400..5974500545 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -25,8 +25,23 @@ import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { PluginManagerController } from './manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from './locales.ts' +import { createNavigationStore } from './navigation-store.ts' import type {} from './slot-contract.ts' +declare module '@deepseek-ai/cordis' { + interface Context { + /** Cross-plugin navigation to the Plugins panel. */ + pluginNavigation: { + /** + * Open a bundle's details without changing the current Session. + * An absent bundle displays the plugin list after loading. + * @param packageName - npm package name of the bundle. + */ + openBundle(packageName: string): void + } + } +} + export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' @@ -49,7 +64,7 @@ export const NS = 'pluginManager' 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'] +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] /** * Contribute the Plugins entry to the sidebar with the management page it @@ -82,21 +97,33 @@ export function apply(ctx: ClientContext): void { // 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) - ctx.slots.inject('main', () => ctx.slots.register({ - name: 'main', - key: PANEL_ID, - locale: NS, - inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), - children: { - '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)) + 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: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), + children: { + '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) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, diff --git a/packages/client/ui-plugin-manager/src/client/navigation-store.ts b/packages/client/ui-plugin-manager/src/client/navigation-store.ts new file mode 100644 index 0000000000..3b05463238 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/navigation-store.ts @@ -0,0 +1,25 @@ +/** Plugin page selection shared by the page and cross-plugin navigation. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' + +/** The plugin list or one bundle, official item, or bundle row. */ +type View = + | { readonly kind: 'list' } + | { readonly kind: 'package'; readonly name: string } + | { readonly kind: 'item'; readonly id: string } + | { readonly kind: 'row'; readonly name: string; readonly rowId: string } + +type NavigationState = { view: View } +type NavigationActions = { setView: (draft: NavigationState, view: View) => void } + +/** + * Create plugin page selection before the first page render. + * @returns the registration-owned navigation store handle. + */ +export function createNavigationStore(): EngineStoreHandle { + return defineStore({ + init: (): NavigationState => ({ view: { kind: 'list' } }), + actions: { + setView: (draft, view: View) => { draft.view = view }, + }, + }) +} 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 d58a3fb0bd..e94702eb6f 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 @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import assert from 'node:assert/strict' import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' @@ -38,8 +39,11 @@ async function bench() { registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })), }, }) + const selectPanel = vi.fn() + ctx.provide('layout', { 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 } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel } } function declare(slots: SlotRegistry): () => void { @@ -54,7 +58,7 @@ function declare(slots: SlotRegistry): () => void { 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']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { @@ -63,7 +67,11 @@ describe('ui-plugin-manager browser plugin', () => { const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() + b.ctx.pluginNavigation.openBundle('dsh-navigation-test') + expect(b.selectPanel).toHaveBeenCalledWith(PANEL_ID) const entry = b.slots.entries('main')[0]! + assert(entry.store && 'create' in entry.store) + expect(entry.store.create().getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) expect(entry.component).toBe(PluginManagerPage) expect(entry.options).toMatchObject({ key: PANEL_ID }) expect(entry.locale).toBe(NS) @@ -113,6 +121,7 @@ describe('ui-plugin-manager browser plugin', () => { expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([]) await fiber.dispose() + expect(b.ctx.get('pluginNavigation')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index c3ecd56900..5bf28cdace 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -9,6 +9,7 @@ import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-te import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ReactNode } from 'react' +import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' @@ -126,7 +127,9 @@ function renderTab( useSessionRetainInfo: unusedStandardHook, useResource: unusedStandardHook, } + const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { + useStore: bindSnapshotSelector(navigation), actions: navigation.actions, ...standard, t, resolveText, @@ -157,6 +160,7 @@ function renderTab( } const { rerender } = render() return { + navigation, store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, @@ -168,6 +172,16 @@ function renderTab( } describe('PluginManagerPage', () => { + it('opens the requested bundle after its inventory arrives and falls back when it is absent', () => { + const b = renderTab({ status: 'loading' }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + b.set({ status: 'ready', packages: [pkg()] }) + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'missing' }) }) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() + }) + it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { const { actions, set } = renderTab({ status: 'loading' }) expect(actions.ensure).toHaveBeenCalledTimes(1) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index 50b00dd654..18472c4d8c 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-voice-input/README.md -README.md: 764962ff496c9fdd91073c2aa391ebceeec23608 -README.zh.md: 105cb641684eb2920c04294e74f1b5389f564c0c +README.md: debf20f7be0eedd5220387471a0be21b337f12ed +README.zh.md: 88c256d37d5abc6a5767c9785773308efd8eb4d0 diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 764962ff49..debf20f7be 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This optional browser plugin adds an outline microphone icon between the model selector and Send. Clicking it opens a recording toolbar with measured audio levels, Cancel and Stop. The waveform stays shorter than the recording buttons. Stopping transcribes into the draft. Recognition preferences and model preparation live in plugin settings; language choices come from the selected provider. +This optional browser plugin adds an outline microphone icon between the model selector and Send. When recognition is ready, clicking it opens a recording toolbar with measured audio levels, Cancel and Stop. The waveform stays shorter than the recording buttons. Stopping transcribes into the draft. Recognition preferences and model preparation live in plugin settings; language choices come from the selected provider. ## Table of Contents @@ -25,7 +25,7 @@ This optional browser plugin adds an outline microphone icon between the model s ## Use this package -Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. +Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before recognition is ready, guidance appears only on click, with no microphone tooltip. Clicking the microphone opens the same guidance dialog; its action opens the voice bundle details without recording or downloading. Preparation in progress, failures and disconnected services instead show a status-oriented prompt. Later or Escape closes the prompt without changing the draft. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. 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. diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index 105cb64168..88c256d37d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -此可选浏览器插件在模型选择器与发送按钮之间添加空心麦克风图标。点击后展开录音栏,显示实际音量以及取消、停止按钮。声波高度低于录音按钮高度。停止后转写到草稿。识别偏好和模型准备放在插件设置中;语言选项来自所选 Provider。 +此可选浏览器插件在模型选择器与发送按钮之间添加空心麦克风图标。识别就绪后,点击展开录音栏,显示实际音量以及取消、停止按钮。声波高度低于录音按钮高度。停止后转写到草稿。识别偏好和模型准备放在插件设置中;语言选项来自所选 Provider。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 +在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。正在准备、准备失败或服务断连时,弹窗改为引导查看服务状态。选择“稍后”或按 Escape 关闭提示,不改变草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 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 dfef2387ee..67f4e171d6 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -8,12 +8,15 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RecordingError, audioBase64, type Recording } from './audio.ts' import type { SpeechReadiness } from './readiness.ts' import { Waveform } from './Waveform.tsx' +import { VoiceSetupDialog } from './VoiceSetupPrompt.tsx' import { NS } from './locales.ts' import { Button, IconCloseOutlineRegular, IconStopFillRegular, IconMicrophoneOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import css from './VoiceInput.module.css' /** Host calls injected without exposing a Cordis Context to React. */ export interface VoiceInputActions { + /** Open the voice bundle details without starting preparation. */ + openSettings: () => void /** @returns one microphone operation owned by the plugin lifecycle. */ createRecording: () => Recording transcribe: (request: TranscriptionRequest, signal: AbortSignal) => Promise> @@ -51,12 +54,14 @@ 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, useSpeechReadiness, t }: VoiceInputProps) { + createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog 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') const [phase, setPhase] = useState('idle'), [message, setMessage] = useState(''), [pending, setPending] = useState('') + const [setupOpen, setSetupOpen] = useState(false) + useEffect(() => { if (usable) setSetupOpen(false) }, [usable]) const current = useRef(), generation = useRef(0) const expanded = phase !== 'idle' useLayoutEffect(() => { onActiveChange(expanded); return () => { onActiveChange(false) } }, [expanded, onActiveChange]) @@ -66,10 +71,10 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, const active = current.current current.current = undefined if (active) { clearTimeout(active.timer); active.abort.abort(); void disposeRecording(active.capture) } - setPending(''); setMessage(''); setPhase('idle') + setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false) } useEffect(() => { - setPending(''); setMessage(''); setPhase('idle') + setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false) const blur = (): void => { if (current.current?.phase === 'recording') cancel() } const visibility = (): void => { if (document.hidden && current.current && current.current.phase !== 'transcribing') cancel() @@ -137,11 +142,16 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, if (run === generation.current) { current.current = undefined; feedback(failureText(failure)) } } } - if (!expanded) return - - + if (!expanded) return <> + + + + { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + return
diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx index 2180284c2f..89d656df10 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx @@ -10,6 +10,25 @@ import type { NS } from './locales.ts' export type VoiceSetupPromptProps = PropsRuntime<'plugins.bundle.activation'> & PropsLocale & Pick, 'useSpeechReadiness'> +type VoiceSetupDialogProps = Pick + & { open: boolean; needsInstallation: boolean } + +/** + * Guide activation or microphone clicks to the existing plugin details. + * @param props - visibility, installation need and navigation callbacks. + * @returns a dismissible prompt that never starts preparation or recording. + */ +export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDetails, t }: VoiceSetupDialogProps) { + return + + + }> +

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

+
+} + /** * Offer navigation to installation without starting a download. * @param props - activation navigation and the shared Host readiness observer. @@ -23,11 +42,5 @@ export function VoiceSetupPrompt({ useSpeechReadiness, onDismiss, onOpenDetails, useEffect(() => { if (phase !== undefined && phase !== 'checking' && !needsSetup) onDismiss() }, [phase, needsSetup, onDismiss]) - return - - - }> -

{t('setupPrompt.body')}

-
+ return } diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index 4d145cc35d..af57d1359b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -10,6 +10,9 @@ export const zh = { 'setupPrompt.body': '语音输入插件已开启。首次使用需要在本机下载并准备识别模型,请前往插件详情页查看空间、内存和时间说明,再开始安装。', 'setupPrompt.later': '稍后', 'setupPrompt.open': '前往安装', + 'setupPrompt.details': '前往语音插件设置', + 'setupPrompt.unavailableTitle': '语音识别尚未就绪', + 'setupPrompt.unavailableBody': '请前往语音插件详情页查看识别服务状态并完成准备', 'setup.local': '将在运行 DSH 的机器上下载本地模型,无需安装 Python 或编译工具。', 'setup.disk': '硬盘空间', 'setup.diskValue': '建议预留约 {gb} GB,包含模型、运行时和下载缓存', @@ -75,7 +78,6 @@ export const zh = { 'preparation.cancelling': '正在取消准备…', cloudReady: '云端语音识别已就绪。', - prepareRequired: '请先在语音插件设置中准备识别服务', discard: '丢弃识别文字', transcribingShort: '识别中…', wakingShort: '唤醒中…', provider: '识别服务', language: '识别语言', @@ -103,6 +105,9 @@ export const en: Record = { 'setupPrompt.body': 'Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup.', 'setupPrompt.later': 'Later', 'setupPrompt.open': 'Go to setup', + 'setupPrompt.details': 'Open voice plugin settings', + 'setupPrompt.unavailableTitle': 'Speech recognition is not ready', + 'setupPrompt.unavailableBody': 'Open the voice plugin details to check recognition status and finish preparation.', 'setup.local': 'Local models will be downloaded to the machine running DSH. No Python or compiler is required.', 'setup.disk': 'Disk space', 'setup.diskValue': 'Allow about {gb} GB for models, runtime, and download caches', @@ -168,7 +173,6 @@ export const en: Record = { 'preparation.cancelling': 'Cancelling preparation…', cloudReady: 'Cloud speech recognition is ready.', - prepareRequired: 'Prepare recognition in the voice plugin settings first', discard: 'Discard transcript', transcribingShort: 'Transcribing…', wakingShort: 'Waking…', provider: 'Recognition service', language: 'Recognition language', 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 a3f0f2c2ae..c7b4eaedbf 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -14,7 +14,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale'] +export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en })) @@ -23,6 +23,7 @@ function registerUi(ctx: Context): void { ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) const actions: VoiceInputInjected = { + openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, hooks: { speechReadiness: readiness.state }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }) @@ -58,7 +59,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'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], 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 638b5d913b..b119659af1 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 @@ -24,6 +24,7 @@ vi.mock('../src/client/readiness.ts', () => ({ observeReadiness: () => ({ /** Narrow the erased registry payload before exercising its registered actions. */ function assertVoiceActions(value: Record): asserts value is Record & VoiceInputInjected { + assert(typeof value.openSettings === 'function') assert(typeof value.createRecording === 'function') assert(typeof value.configure === 'function') assert(typeof value.prepare === 'function') @@ -42,6 +43,8 @@ async function fixture(fail = false) { } } new Remote() + const openBundle = vi.fn() + 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: {} })) @@ -56,7 +59,7 @@ async function fixture(fail = false) { } } as never, () => null) if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot failed') }) - return { ctx, unmount, configure, prepare, cancelPreparation, transcribe } + return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { @@ -69,6 +72,8 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a expect(entry).toMatchObject({ locale: 'voice-input' }) const actions = entry!.inject!() assertVoiceActions(actions) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') const finished = actions.createRecording() assert(finished instanceof Recording) await finished.dispose() diff --git a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx index d3b9a22ac9..655cb1dee4 100644 --- a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx @@ -116,7 +116,7 @@ it('persists settings and reports disconnection in the detail card', async () => { id: 'cloud' as SpeechProviderId, name: 'Cloud', location: 'cloud', languages: ['auto', 'en', 'zh', 'ja', 'fr'], preparation: { phase: 'ready' } }], } }) const configure = vi.fn(async () => {}) - const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, + const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), transcribe: vi.fn(), createRecording: vi.fn() } const view = render() fireEvent.change(screen.getByLabelText(zh.provider), { target: { value: 'cloud' } }) 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 698c3d85ee..5ee8540198 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 @@ -6,7 +6,7 @@ import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/d import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SpeechProviderId, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import type { SpeechPreparationState, SpeechProviderId, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { VoiceInput, type VoiceInputProps } from '../src/client/VoiceInput.tsx' import { RecordingError, Recording } from '../src/client/audio.ts' @@ -30,7 +30,7 @@ function fixture(recording?: Recording) { const transcribe = vi.fn<(request: unknown, signal: AbortSignal) => Promise>>( async () => ({ ok: true, value: transcript })) const props: VoiceInputProps = { sessionId: 'one' as SessionId, inputActions, transcribe, locked: false, onActiveChange: vi.fn(), - prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), + openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, t: makeTranslate(zh, commonZh) } const view = render() @@ -172,15 +172,18 @@ it('bounds recordings, indicates idle wake-up, and cancels an in-flight transcri expect(b.inputActions.insertText).not.toHaveBeenCalled() }) -it('rejects excessive audio and disables unavailable or locked recognition', async () => { +it('rejects excessive audio and keeps locked recognition disabled', async () => { const b = fixture(); b.capture.stop.mockResolvedValueOnce(new Uint8Array(101)) await start(); stop(); await screen.findByText(zh.tooLarge) expect(b.transcribe).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: zh.cancel })) act(() => { b.readiness.set({ catalog: null, connected: false, error: null }) }) - expect(screen.getByRole('button', { name: zh.start }).disabled).toBe(true) + expect(screen.getByRole('button', { name: zh['setupPrompt.details'] }).disabled).toBe(false) b.view.rerender() - fireEvent.click(screen.getByRole('button', { name: zh.start })) + const locked = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + expect(locked.disabled).toBe(true) + fireEvent.click(locked) + expect(screen.queryByRole('dialog')).toBeNull() expect(b.capture.start).toHaveBeenCalledOnce() }) @@ -291,13 +294,63 @@ it.each(['escape', 'hidden', 'session'])('discards pending permission after %s', expect(b.transcribe).not.toHaveBeenCalled() }) -it('explains preparation on hover even when the microphone is disabled', () => { +it('offers installation on click without a preparation tooltip', () => { const b = fixture(), state = b.readiness.getSnapshot() act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, ] } }) }) - const mic = screen.getByRole('button', { name: zh.start }) - expect(mic.disabled).toBe(true) + const mic = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + expect(mic.disabled).toBe(false) fireEvent.mouseEnter(mic.parentElement!) - expect(screen.getByRole('tooltip').textContent).toBe(zh.prepareRequired) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.click(mic) + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.body']) + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.props.prepare).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.open'] })) + expect(b.props.openSettings).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each([ + { phase: 'checking', step: 'check', startedAt: 0 }, + { phase: 'downloading', resource: 'model', completedBytes: 0 }, + { phase: 'failed', message: 'download failed' }, + { phase: 'cancelled' }, +])('guides $phase recognition to its status page without starting recording or preparation', (preparation) => { + const b = fixture(), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.details'] })) + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.unavailableBody']) + expect(screen.queryByText(zh['setupPrompt.body'])).toBeNull() + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.props.prepare).not.toHaveBeenCalled() + fireEvent.click(screen.getAllByRole('button', { name: zh['setupPrompt.details'] }).at(-1)!) + expect(b.props.openSettings).toHaveBeenCalledOnce() +}) + +it.each(['later', 'escape', 'ready', 'session'])('dismisses unavailable recognition guidance on %s without changing the draft', (reason) => { + const b = fixture(), ready = b.readiness.getSnapshot() + act(() => { b.readiness.set({ catalog: null, connected: false, error: 'offline' }) }) + const open = () => { fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.details'] })) } + open() + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.unavailableTitle']) + if (reason === 'later') fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.later'] })) + if (reason === 'escape') fireEvent.keyDown(document, { key: 'Escape' }) + if (reason === 'ready') act(() => { b.readiness.set(ready) }) + if (reason === 'session') b.view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(b.props.openSettings).not.toHaveBeenCalled() + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.inputActions.setDraft).not.toHaveBeenCalled() + expect(b.inputActions.insertText).not.toHaveBeenCalled() + if (reason === 'ready') { + act(() => { b.readiness.set({ ...ready, connected: false }) }) + expect(screen.queryByRole('dialog')).toBeNull() + } else { + open() + expect(screen.getByRole('dialog')).toBeTruthy() + } }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0dde565986..f594f72866 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -186,6 +186,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API', + pluginNavigation: 'client-side bundle navigation — packages/client/ui-plugin-manager/README.md owns the API', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API', modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API', diff --git a/snapshots/web/voice-input/setup.expected.md b/snapshots/web/voice-input/setup.expected.md new file mode 100644 index 0000000000..1cfcd160e0 --- /dev/null +++ b/snapshots/web/voice-input/setup.expected.md @@ -0,0 +1,6 @@ +- dialog "Set up voice input before recording": + - heading "Set up voice input before recording" [level=2] + - button "Cancel" + - paragraph: Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup. + - button "Later" + - button "Go to setup" diff --git a/snapshots/web/voice-input/unavailable.expected.md b/snapshots/web/voice-input/unavailable.expected.md new file mode 100644 index 0000000000..1f33e445ca --- /dev/null +++ b/snapshots/web/voice-input/unavailable.expected.md @@ -0,0 +1,6 @@ +- dialog "Speech recognition is not ready": + - heading "Speech recognition is not ready" [level=2] + - button "Cancel" + - paragraph: Open the voice plugin details to check recognition status and finish preparation. + - button "Later" + - button "Open voice plugin settings" From 730446172ec576e0b168e1673c84e20a0df8c17a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 11:11:21 +0800 Subject: [PATCH 03/41] test(voice-input): address setup guidance review feedback --- .../tests/components.client.spec.tsx | 11 ++++++++++- .../src/client/PreparationCard.tsx | 3 ++- .../src/client/VoiceSetupPrompt.tsx | 2 +- .../tests/preparation.client.spec.tsx | 4 ++-- .../tests/voice-input.client.spec.tsx | 4 ++-- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 5bf28cdace..71ee20aa55 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -158,9 +158,10 @@ function renderTab( return body(owner.view, owner, 'form' in owner ? owner.form as ConfigPageForm | undefined : undefined) }, } - const { rerender } = render() + const { rerender, unmount } = render() return { navigation, + unmount, store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, @@ -182,6 +183,14 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() }) + it('resets the open page to the list when the page unmounts', () => { + const b = renderTab({ packages: [pkg()] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() + b.unmount() + expect(b.navigation.getSnapshot()).toEqual({ view: { kind: 'list' } }) + }) + it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { const { actions, set } = renderTab({ status: 'loading' }) expect(actions.ensure).toHaveBeenCalledTimes(1) diff --git a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx index e67edfb23f..8d9fcaff5e 100644 --- a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx @@ -128,7 +128,8 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio } /** Recognition preferences and preparation cards shared by plugin details and Settings. */ -export function VoicePreparation({ useSpeechReadiness, ...props }: InjectFace & PropsLocale) { +export function VoicePreparation({ useSpeechReadiness, ...props }: Pick, + 'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation'> & PropsLocale) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog const [saving, setSaving] = useState(false), [error, setError] = useState('') const configure = async (patch: SpeechSelectionPatch): Promise => { diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx index 89d656df10..eb9098afea 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx @@ -1,4 +1,4 @@ -/** First-use guidance after explicit enablement, once the Host finishes inspecting its cache. */ +/** Shared setup dialog for bundle activation and unready microphone clicks. */ import { useEffect } from 'react' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx index 655cb1dee4..5f1051bc5f 100644 --- a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx @@ -116,8 +116,8 @@ it('persists settings and reports disconnection in the detail card', async () => { id: 'cloud' as SpeechProviderId, name: 'Cloud', location: 'cloud', languages: ['auto', 'en', 'zh', 'ja', 'fr'], preparation: { phase: 'ready' } }], } }) const configure = vi.fn(async () => {}) - const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, openSettings: vi.fn(), - prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), transcribe: vi.fn(), createRecording: vi.fn() } + const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, + prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}) } const view = render() fireEvent.change(screen.getByLabelText(zh.provider), { target: { value: 'cloud' } }) await waitFor(() => { expect(configure).toHaveBeenCalledWith({ providerId: 'cloud' }) }) 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 5ee8540198..d03bb2639b 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 @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Click capture preserves draft edits and Session ownership without composer banners. */ import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -327,7 +327,7 @@ it.each([ expect(screen.queryByText(zh['setupPrompt.body'])).toBeNull() expect(b.capture.start).not.toHaveBeenCalled() expect(b.props.prepare).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByRole('button', { name: zh['setupPrompt.details'] }).at(-1)!) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: zh['setupPrompt.details'] })) expect(b.props.openSettings).toHaveBeenCalledOnce() }) From 9a9a331963f0db5876ba84ddfb8ab18d1114d5e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 11:55:27 +0800 Subject: [PATCH 04/41] fix(voice-input): preserve draft focus during setup guidance --- apps/web/tests/voice-input.e2e.ts | 30 ++++++++++++++-- .../client/ui-plugin-manager/README.i18n.yaml | 4 +-- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 1 - .../ui-plugin-manager/src/client/index.ts | 3 ++ .../tests/browser-plugin.client.spec.tsx | 29 +++++++++++++-- .../tests/components.client.spec.tsx | 14 +++++--- .../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 | 5 +-- .../src/client/VoiceSetupDialog.tsx | 25 +++++++++++++ .../src/client/VoiceSetupPrompt.tsx | 23 ++---------- .../src/client/locales.ts | 6 ++-- .../tests/voice-input.client.spec.tsx | 36 ++++++++++++++++--- .../web/voice-input/unavailable.expected.md | 2 +- 17 files changed, 139 insertions(+), 51 deletions(-) create mode 100644 packages/experimental/client-ui-voice-input/src/client/VoiceSetupDialog.tsx diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index 545cd9ba11..82634fa7eb 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -71,16 +71,27 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan await page.goto(scaffold.authenticatedUrl) await connectFreshWorkspace(page, scaffold.workspaceCwd) const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true }) - const setupMic = page.getByRole('button', { name: 'Open voice plugin settings', exact: true }) + const setupMic = page.getByRole('button', { name: 'Open voice input setup', exact: true }) + await input.fill(prefix) await setupMic.click() const setup = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true }) await setup.waitFor() + const setupAction = setup.getByRole('button', { name: 'Go to setup', exact: true }) + await expect.poll(() => setupAction.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.type('NoDraftEdits') + expect(await input.innerText()).toBe(prefix) + expect(messages).toBe(0) + await page.keyboard.press('Tab') + expect(await setup.getByRole('button', { name: 'Cancel', exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Shift+Tab') + expect(await setupAction.evaluate(element => element === document.activeElement)).toBe(true) await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/setup.expected.md', import.meta.url)), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) await setup.getByRole('button', { name: 'Later', exact: true }).click() expect(await setup.count()).toBe(0) + expect(await input.evaluate(element => element === document.activeElement)).toBe(true) expect(recognize).not.toHaveBeenCalled() - preparation = { phase: 'failed', message: 'Model preparation failed' } + preparation = { phase: 'checking', step: 'check', startedAt: 0 } for (const listener of readinessListeners) listener() await setupMic.click() const unavailable = page.getByRole('dialog', { name: 'Speech recognition is not ready', exact: true }) @@ -93,16 +104,29 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(420) await page.keyboard.press('Escape') await unavailable.waitFor({ state: 'hidden' }) + expect(await input.evaluate(element => element === document.activeElement)).toBe(true) + expect(await input.innerText()).toBe(prefix) + preparation = { phase: 'failed', message: 'Model preparation failed' } + for (const listener of readinessListeners) listener() await setupMic.click() - await unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true }).click() + const detailsAction = unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true }) + await expect.poll(() => detailsAction.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') await page.setViewportSize({ width: 1280, height: 900 }) await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-voice-input-bundle"]') .getByRole('button', { name: 'Retry preparation', exact: true }).waitFor() expect(await unavailable.count()).toBe(0) + expect(messages).toBe(0) + expect(recognize).not.toHaveBeenCalled() preparation = { phase: 'standby' } for (const listener of readinessListeners) listener() await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click() await mic.waitFor() + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').waitFor() + expect(await page.locator('[data-plugin-detail]').count()).toBe(0) + await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click() + await mic.waitFor() const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox() const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox() expect(micBox!.x).toBeGreaterThan(modelBox!.x) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index f9ab735c2a..122b351bc8 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -42,8 +42,8 @@ en: 21a0bbe8eb92229c zh: 096ff63db61992bb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots: - en: e049024daecaa3db - zh: d33426421a4a3d3a + en: f2bfff32346015bf + zh: b9aedc96182004ae /deepseek-ai-dsh-client-ui-plugin-manager/further-exploration: en: f9a42dfed0360d6a zh: 455a70bcb6c69624 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index ca731ad79d..aac169b8de 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -101,7 +101,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the cards, a bundle, an official plugin, or a bundle row; leaving the page resets it to the list. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the cards, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 27cda83364..8a249d25d8 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -101,7 +101,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择卡片、某个组合包、某个官方插件或组合包的某一行;离开页面时重置为列表。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择卡片、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 613f6b073e..e89c11d784 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1148,7 +1148,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const view = props.useStore(state => state.view), { setView } = props.actions const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) - useEffect(() => () => { setView({ kind: 'list' }) }, [setView]) // A package an install just enabled: scroll it into view and mark it for a moment. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } useEffect(() => { diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 5974500545..0bceeabe9d 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -116,6 +116,9 @@ export function apply(ctx: ClientContext): void { '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', { openBundle: (packageName: string) => { ctx.layout.selectPanel(PANEL_ID) 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 e94702eb6f..f9cff227dc 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 @@ -4,6 +4,8 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +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 { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' @@ -39,11 +41,12 @@ async function bench() { registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })), }, }) - const selectPanel = vi.fn() - ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal, + const panelInfo = createSnapshotStore({ activePanelId: null }) + const selectPanel = vi.fn((activePanelId) => { panelInfo.set({ activePanelId }) }) + 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 } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } } function declare(slots: SlotRegistry): () => void { @@ -57,6 +60,26 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-plugin-manager browser plugin', () => { + it('resets bundle selection when leaving Plugins and releases its panel observer with the registration', async () => { + const b = await bench() + const removeRoot = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('main')[0]! + assert(entry.store && 'create' in entry.store) + const navigation = entry.store.create() + b.ctx.pluginNavigation.openBundle('dsh-navigation-test') + expect(b.panelInfo.getSnapshot().activePanelId).toBe(PANEL_ID) + expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) + b.selectPanel(null) + expect(navigation.getSnapshot()).toEqual({ view: { kind: 'list' } }) + b.selectPanel(PANEL_ID) + expect(navigation.getSnapshot()).toEqual({ view: { kind: 'list' } }) + b.ctx.pluginNavigation.openBundle('dsh-navigation-test') + removeRoot() + b.selectPanel(null) + expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) + }) + 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']) }) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index d7a7040da5..abf8b9e726 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -8,7 +8,7 @@ import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { ReactNode } from 'react' +import { StrictMode, type ReactNode } from 'react' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' @@ -166,6 +166,7 @@ function renderTab( const { rerender, unmount } = render() return { navigation, + props, unmount, store, actions, @@ -188,12 +189,15 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() }) - it('resets the open page to the list when the page unmounts', () => { + it('preserves the requested bundle through StrictMode effect replay and page remounts', () => { const b = renderTab({ packages: [pkg()] }) - act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) - expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() b.unmount() - expect(b.navigation.getSnapshot()).toEqual({ view: { kind: 'list' } }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + const view = render() + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() + view.unmount() + render() + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() }) it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index 37303d0f97..b4dcb861cc 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: 73ea82688bda68b2 - zh: fdca09713d7010e1 + en: 44f25ad14ceda123 + zh: 43c4680be42af1cf /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: 18807008bccfdb6e zh: bb5a82c92d4aa056 diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index debf20f7be..afa8c2c135 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -25,7 +25,7 @@ This optional browser plugin adds an outline microphone icon between the model s ## Use this package -Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before recognition is ready, guidance appears only on click, with no microphone tooltip. Clicking the microphone opens the same guidance dialog; its action opens the voice bundle details without recording or downloading. Preparation in progress, failures and disconnected services instead show a status-oriented prompt. Later or Escape closes the prompt without changing the draft. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. +Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before recognition is ready, guidance appears only on click, with no microphone tooltip. Clicking the microphone opens the same guidance dialog; its action opens the voice bundle details without recording or downloading. Cache inspection, preparation in progress, failures and disconnected services instead offer navigation to status, progress or error details. The dialog moves focus to its navigation action and keeps Tab traversal inside it; Later or Escape restores the previous focus without changing or submitting the draft. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. 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. diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index 88c256d37d..282acf7431 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。正在准备、准备失败或服务断连时,弹窗改为引导查看服务状态。选择“稍后”或按 Escape 关闭提示,不改变草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 +在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。缓存检查、正在准备、准备失败或服务断连时,弹窗改为引导查看状态、进度或错误详情。弹窗将焦点移到导航按钮,并将 Tab 切换约束在弹窗内;选择“稍后”或按 Escape 关闭时恢复此前焦点,不改变或提交草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 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 67f4e171d6..745f053b6f 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -8,7 +8,7 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RecordingError, audioBase64, type Recording } from './audio.ts' import type { SpeechReadiness } from './readiness.ts' import { Waveform } from './Waveform.tsx' -import { VoiceSetupDialog } from './VoiceSetupPrompt.tsx' +import { VoiceSetupDialog } from './VoiceSetupDialog.tsx' import { NS } from './locales.ts' import { Button, IconCloseOutlineRegular, IconStopFillRegular, IconMicrophoneOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import css from './VoiceInput.module.css' @@ -145,7 +145,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, if (!expanded) return <> + & Pick, 'onDismiss' | 'onOpenDetails'> + & { open: boolean; needsInstallation: boolean } + +/** + * Guide activation or microphone clicks to the existing plugin details. + * @param props - visibility, installation need and navigation callbacks. + * @returns a dismissible prompt that never starts preparation or recording. + */ +export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDetails, t }: VoiceSetupDialogProps) { + return + + + }> +

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

+
+} diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx index eb9098afea..afce24f6ce 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx @@ -1,6 +1,6 @@ -/** Shared setup dialog for bundle activation and unready microphone clicks. */ +/** Activation guidance after the Host inspects local recognition resources. */ import { useEffect } from 'react' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { VoiceSetupDialog } from './VoiceSetupDialog.tsx' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type { VoiceInputInjected } from './VoiceInput.tsx' @@ -10,25 +10,6 @@ import type { NS } from './locales.ts' export type VoiceSetupPromptProps = PropsRuntime<'plugins.bundle.activation'> & PropsLocale & Pick, 'useSpeechReadiness'> -type VoiceSetupDialogProps = Pick - & { open: boolean; needsInstallation: boolean } - -/** - * Guide activation or microphone clicks to the existing plugin details. - * @param props - visibility, installation need and navigation callbacks. - * @returns a dismissible prompt that never starts preparation or recording. - */ -export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDetails, t }: VoiceSetupDialogProps) { - return - - - }> -

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

-
-} - /** * Offer navigation to installation without starting a download. * @param props - activation navigation and the shared Host readiness observer. diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index af57d1359b..baa3f9bd33 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -10,9 +10,10 @@ export const zh = { 'setupPrompt.body': '语音输入插件已开启。首次使用需要在本机下载并准备识别模型,请前往插件详情页查看空间、内存和时间说明,再开始安装。', 'setupPrompt.later': '稍后', 'setupPrompt.open': '前往安装', + 'setupPrompt.trigger': '打开语音输入引导', 'setupPrompt.details': '前往语音插件设置', 'setupPrompt.unavailableTitle': '语音识别尚未就绪', - 'setupPrompt.unavailableBody': '请前往语音插件详情页查看识别服务状态并完成准备', + 'setupPrompt.unavailableBody': '请前往语音插件详情页查看识别服务状态、准备进度或错误信息', 'setup.local': '将在运行 DSH 的机器上下载本地模型,无需安装 Python 或编译工具。', 'setup.disk': '硬盘空间', 'setup.diskValue': '建议预留约 {gb} GB,包含模型、运行时和下载缓存', @@ -105,9 +106,10 @@ export const en: Record = { 'setupPrompt.body': 'Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup.', 'setupPrompt.later': 'Later', 'setupPrompt.open': 'Go to setup', + 'setupPrompt.trigger': 'Open voice input setup', 'setupPrompt.details': 'Open voice plugin settings', 'setupPrompt.unavailableTitle': 'Speech recognition is not ready', - 'setupPrompt.unavailableBody': 'Open the voice plugin details to check recognition status and finish preparation.', + 'setupPrompt.unavailableBody': 'Open the voice plugin details to check recognition status, preparation progress, or errors.', 'setup.local': 'Local models will be downloaded to the machine running DSH. No Python or compiler is required.', 'setup.disk': 'Disk space', 'setup.diskValue': 'Allow about {gb} GB for models, runtime, and download caches', 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 d03bb2639b..cc27020f71 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 @@ -178,9 +178,9 @@ it('rejects excessive audio and keeps locked recognition disabled', async () => expect(b.transcribe).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: zh.cancel })) act(() => { b.readiness.set({ catalog: null, connected: false, error: null }) }) - expect(screen.getByRole('button', { name: zh['setupPrompt.details'] }).disabled).toBe(false) + expect(screen.getByRole('button', { name: zh['setupPrompt.trigger'] }).disabled).toBe(false) b.view.rerender() - const locked = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + const locked = screen.getByRole('button', { name: zh['setupPrompt.trigger'] }) expect(locked.disabled).toBe(true) fireEvent.click(locked) expect(screen.queryByRole('dialog')).toBeNull() @@ -299,8 +299,9 @@ it('offers installation on click without a preparation tooltip', () => { act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, ] } }) }) - const mic = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + const mic = screen.getByRole('button', { name: zh['setupPrompt.trigger'] }) expect(mic.disabled).toBe(false) + expect(mic.getAttribute('aria-haspopup')).toBe('dialog') fireEvent.mouseEnter(mic.parentElement!) expect(screen.queryByRole('tooltip')).toBeNull() fireEvent.click(mic) @@ -322,7 +323,7 @@ it.each([ act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ { ...state.catalog!.providers[0]!, preparation }, ] } }) }) - fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.details'] })) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] })) expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.unavailableBody']) expect(screen.queryByText(zh['setupPrompt.body'])).toBeNull() expect(b.capture.start).not.toHaveBeenCalled() @@ -331,10 +332,35 @@ it.each([ expect(b.props.openSettings).toHaveBeenCalledOnce() }) +it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => { + const b = fixture() + render(