diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 3f8444bcfa..d4ad622140 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -9,8 +9,8 @@ en: c8b932b02a4e1c80 zh: 96c466be1a521f3f /agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision: - en: b57eb0f268033ff1 - zh: 4143382a50181414 + en: 2e08d801099cbe18 + zh: 8c5ab162ada9cc41 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology: en: 5ea3ddfae82df181 zh: 2d05d95e51081696 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 91e212ad9f..74e88534ee 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -32,7 +32,7 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov - A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. - The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. -This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 27b7777f23..ac5f935c92 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -32,7 +32,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 - 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 - 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 -这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 49e0e11ef4..061ddeed50 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -21,8 +21,8 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 9d885e8d58bc8bbb - zh: 41a9779fbdea8a1a + en: 9a4ba22142a7be24 + zh: 8553b02a923641a3 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: en: b2a740a1ab7a5002 zh: a48be6ca6fc65912 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 8349e7cf31..34d346c96a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f28f3da059..6e1e2c51b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 4573f0a33f..e4b4714af1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -24,8 +24,8 @@ en: 26e4b71366bebd1b zh: 10598e12b28c586e /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification: - en: fbd101d4576afa92 - zh: 35f5d3c2f6cad6a8 + en: 983da6d0c09f5c10 + zh: ca7c86117a1a4d5f /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: en: c4d86feb135d2ac4 zh: 67c56c0bc2213949 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 967c7285df..8ad0af1d21 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 87318c3316..613db6dacd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 4312f50471..97e7927b58 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md /agent-note-adjacent-agents-share-one-steer-send-message-operation: - en: 4784b23b16ce0d58 - zh: 7fb5755eebab37b0 + en: 9d0af2bae38b1eec + zh: 8a6e8502a5195548 /agent-note-adjacent-agents-share-one-steer-send-message-operation/problem: en: 5dc4c218f648c27e zh: b48531fac94b25db diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 45a36f1428..a4cfbc91cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-27-adjacent-agent-steer-messaging.zh.md) -The adjacency, discovery, and parent-lifetime restrictions below are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md); the remaining decisions stay active. +The adjacency, discovery, and parent-lifetime restrictions below are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md); the remaining decisions stay active. The tool identity marker and initial-task communication guidance below are superseded by [tool-owned communication](2026-10-22-subagent-tool-owned-communication.md). ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index 1bf1b5dbbc..d907eaeac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-27-adjacent-agent-steer-messaging.md) | 中文 -邻接消息、发现范围和父子生命周期限制已由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)替代;其余决策仍然有效。 +邻接消息、发现范围和父子生命周期限制已由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)替代;其余决策仍然有效。 下文的工具身份标记和初始任务通信指导由[工具拥有通信指导](2026-10-22-subagent-tool-owned-communication.zh.md)取代。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index ddf55f9063..f8de1ed3b1 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -9,14 +9,14 @@ en: 98b49b900b8d2f84 zh: 557faac2976592be /agent-note-explicit-agent-identity-at-runtime-boundaries/decision: - en: 1c628cf9c743c639 - zh: d969b2475fe4a155 + en: 4a3ab5108f37520f + zh: d13fcd448101a95d /agent-note-explicit-agent-identity-at-runtime-boundaries/verification: - en: 85130faaa171036b - zh: 06e5e9fab32dbfb4 + en: ce1733edf03f0387 + zh: 8e1ca2b69e81968e /agent-note-explicit-agent-identity-at-runtime-boundaries/alternatives-considered: - en: 2c41456509d54d21 - zh: 5704ee9a2274e300 + en: 34f5f8571b21897c + zh: cf16094bc0027ca1 /agent-note-explicit-agent-identity-at-runtime-boundaries/consequences: - en: 08ab86b89fc3ec6f - zh: 3866e6557858543a + en: 0b5914067f14282e + zh: c41d21aeb28e357f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 16cd668f65..336151fa83 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -14,34 +14,34 @@ Without an explicit owner, `SubagentManager` creates and resumes children throug ## Decision -Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; each subagent Activation retains its exact runtime root; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` receives the Agent explicitly and installs through the supplied Context before Agent publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. -`SubagentManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentManager.rootOf(agent)` reads runtime membership from existing Activations; both the child and root must match their live registry objects. Ordinary Sessions resolve to themselves, and unmanaged subagent headers grant no membership. Durable `parentSession` describes creation edges; an Activation retains the exact root while intermediate creators are offline, and same-id replacements cannot inherit it. Root-only consumers classify the explicit subagent origin. The [scope reference](../../../../packages/core/scope/README.md) and [Agent loop reference](../../../../packages/core/agent-loop/README.md) document registration and lifecycle behavior. The [initiator-scope decision](2026-07-15-agent-initiator-scope.md) owns private-chain rationale; explicit runtime ownership does not change causal initiation or registration lifetime. ## Verification -Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. +Managed-child tests verify intermediate-creator disposal and exact-instance checks after child or root replacement. SDK approval tests verify routing during a turn after the direct creator exits. Schedule and user-question tests reject delegated Sessions while ordinary historical forks remain independent. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Agent publication; standing-preset tests verify per-Session policy sampling and inheritance. ## Alternatives considered **Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. -**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the subagent Activation retains the exact root supplied at admission. **Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. **Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. -**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. +**Use durable Session lineage.** `parentSession` records creation ancestry across process lifetimes. An intermediate creator can be offline while its descendant remains managed by a live root; durable ids also cannot distinguish replaced Agent instances. Historical ordinary forks may retain parent metadata without being delegated. ## Consequences Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. -Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. +Continuable children have one runtime root independent of creator residency. The subagent manager owns tree teardown and local membership; the core factory and registry retain only instance creation, publication, and disposal. Third-party callers create managed children through the subagent service rather than assigning ownership to arbitrary factory-created Agents. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 3d4131af01..063f0490bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -14,34 +14,34 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 ## 决策 -运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;每个 subagent Activation 保留其确切运行时树根;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式接收 Agent,并在 Agent 发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 -`SubagentManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentManager.rootOf(agent)` 从已有 Activation 读取运行时成员关系;子级和树根都必须匹配注册表中的存活对象。普通 Session 返回自身,非托管的 subagent header 不授予成员身份。持久化 `parentSession` 描述创建关系;中间创建者离线时,Activation 保留确切树根,同 id 替代实例不能继承它。仅限根级的消费方依据显式 subagent 来源分类。 [作用域参考](../../../../packages/core/scope/README.zh.md)与 [Agent loop 参考](../../../../packages/core/agent-loop/README.zh.md)说明注册和生命周期行为。[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)负责私有调用链理由;显式运行时归属不改变因果发起关系或注册生命周期。 ## 验证 -Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 +托管子级测试验证中间创建者释放,以及子级或树根替换后的确切实例检查。SDK 审批测试验证直接创建者退出后的轮次内路由。Schedule 与用户问题测试拒绝委派 Session,历史普通 fork 则保持独立。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Agent 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 ## 考虑过的替代方案 **保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 -**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;subagent Activation 保留准入时传入的确切树根。 **分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 **使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 -**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录创建祖先关系。中间创建者可以离线,而后代仍由存活树根管理;持久 id 也无法区分替换后的 Agent 实例。历史普通 fork 可能保留父级元数据,但并不属于委派。 ## 后果 生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 -可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 +可续跑子级拥有独立于创建者驻留状态的唯一运行时树根。subagent 管理器拥有整树 teardown 和本地成员关系;核心工厂与注册表仅保留实例创建、发布和释放。第三方调用方通过 subagent 服务创建托管子级,而非给任意工厂创建的 Agent 指定归属。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml index e9aab02a20..79f9416898 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml @@ -9,8 +9,8 @@ en: 6bb727a659ba1503 zh: d2ebf5c8164a807e /agent-note-current-profile-plugin-management-shares-cli-transactions/decision: - en: 863b4369c39f3933 - zh: 7dd44a91ccaaef8f + en: d2e13562b5aef903 + zh: 07face30189c07fc /agent-note-current-profile-plugin-management-shares-cli-transactions/alternatives-considered: en: 30c609de89084351 zh: 27f2747ad9d9b752 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md index 8d4d4c40f0..c9d522d4e7 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md @@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted. -This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery. +This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Standard and Creator modes and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery. CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate. diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md index a8dce606d0..de2b995047 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md @@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安 profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。 -这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。 +这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。标准模式和创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml index e2da7be945..8d8498f8bb 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml @@ -9,8 +9,8 @@ en: 44615a7a0939054e zh: b8efa721321ba0d7 /agent-note-creator-mode-installs-persistent-plugin-bundles/decision: - en: c0d74db1b2b16576 - zh: b8713a6aadf007f9 + en: 38ecabc170c418c7 + zh: d380f53fa508328e /agent-note-creator-mode-installs-persistent-plugin-bundles/alternatives-considered: en: af527d8aabdfd59c zh: 34388da992d2ab56 diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md index 5723eba63e..8fa2b574f6 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md @@ -12,6 +12,8 @@ Agents need to install capabilities and use them during the same conversation. G Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior. +The default Standard mode enables the same tool under the same profile condition and approval gate. Without it, Standard sessions did not install plugins directly and spent extra turns and tokens working around the missing tool; the change also moves Standard and Creator toward one capability set. A full-access Standard session can therefore install Host code without a separate prompt, as its shell already can. PTC and Minimal keep the tool disabled. + The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset reference](../../../../packages/extensions/cordis-host-runner/README.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures. Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result. diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md index 33c1fd867f..b5c0ad0cb2 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md @@ -12,6 +12,8 @@ agent 需要安装能力并在同一段对话中使用。生成代码工具在 创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。 +默认的标准模式以相同的 profile 条件和审批门槛启用同一工具。缺少该工具时,标准模式会话不会直接安装插件,而要额外花费轮次和 token 绕过;此改动也让标准模式和创造模式朝同一能力集收敛。因此,完整权限的标准模式会话无需单独审批即可安装 Host 代码,与其 shell 已有的能力一致。PTC 和 Minimal 仍禁用该工具。 + 模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集参考](../../../../packages/extensions/cordis-host-runner/README.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。 除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index 2663220c51..c0587791fd 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -15,5 +15,5 @@ en: b78785f2a603eba8 zh: b8c268228da7e2e7 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 93c32b795b71a1cc - zh: d42b5c563341c898 + en: 60d7bf28fa5a8fd1 + zh: a17b222e7aca01e2 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 1b505eec4b..a40b85601c 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -30,7 +30,7 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool ## Consequences -Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still consolidate on tool changes; explicit series starts and surface replacements still consolidate on every route. No persisted fields or Session format revision are added. +Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still start a new series on tool changes; explicit series starts and surface replacements consolidate the prompt only when the effective text differs, and otherwise leave the surviving prompt node in place. No persisted fields or Session format revision are added. The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 6a8fb256c5..372dcb27af 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -30,7 +30,7 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to ## Consequences -工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时归并提示词;所有路由仍在显式新序列和 surface 替换时归并。不增加持久字段或 Session 格式版本。 +工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时开启新序列;显式新序列和 surface 替换只在有效文本不同时归并提示词,否则保留存活的提示词节点。不增加持久字段或 Session 格式版本。 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index 5d8a536a53..4a705f767e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: d6328ba2b218f2ac - zh: 3731561313b1ad5e + en: 191ca8bffb101db2 + zh: 1365bef5cd830a5c /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: en: d6f40bf02df37ab7 zh: e5b85ce9d41e8754 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index a2d39b0ffb..2c5e6a73b5 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A ## Decision -`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the Official group displays that metadata. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles. +`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the [plugin manager page](../../../../packages/client/ui-plugin-manager/README.md#use-this-package) displays that metadata for eligible bundles and retains explicit activation for default-hidden ones. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles. An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. External browser and Cua Driver providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 15bc65b56f..31afdf2f3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 决策 -`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。 +`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;[插件管理页](../../../../packages/client/ui-plugin-manager/README.zh.md#use-this-package)为符合展示条件的组合包显示这些元数据,并保留默认隐藏项的显式启用入口。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。 可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。外部浏览器与 Cua Driver 提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml index 71c61113a6..d8e3f3fcaf 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 2eeb916eb65c66e7 zh: 7bf5c0abb8bb61e4 /agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision: - en: 42e800c4cd3b6ebe - zh: 06fabe5a9681fa27 + en: 0125e33ba966c8ac + zh: bf6af389336999af /agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered: en: 7c7880fdb0cc95f6 zh: 2b8a539192626789 diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md index 75265db1ce..867198fba2 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md @@ -12,8 +12,8 @@ The account route's catalog was deployment configuration: a build advertised the - [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate. - A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`. -- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. -- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package. +- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge the listing does not declare, such as image budgets and a protocol mode the listing omits or sets to `null`; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. +- The shared transport gains only two provider hooks, `connect` and `admit`. Listing, gating, and merging live in the shared transport, which the [official route](2026-10-22-deepseek-official-model-listing.md) also uses. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md index 7199091c73..37487fbc88 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md @@ -12,8 +12,8 @@ Status: implemented - [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。 - 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。 -- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 -- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。 +- 列表中的能力逐字段覆盖内置能力;内置条目保留列表没有声明的客户端知识,例如图片预算,以及列表没有给出或设为 `null` 的协议模式;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 +- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子。列表、门控与合并位于共享传输包中,[官方路由](2026-10-22-deepseek-official-model-listing.zh.md)也使用它们。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml index 46925831d8..8b028995ae 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml @@ -9,11 +9,11 @@ en: abd0263d14861926 zh: e1736cd08e87f795 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 52b45f1ac62e65ab - zh: 7f83cd4fb8dd09a6 + en: fdd39b7e8fa89d70 + zh: 4a0dfc81509e5485 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 1737484c099fab52 zh: 303b144c52d167bd /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 6ec74e7ac065bd5e - zh: 04d029059dea732b + en: 79d8d66071453d1f + zh: f940f0b6d74148d6 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md index 607dc68b7a..2cc2c8be43 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -20,6 +20,8 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d `Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls. +The last copy of the canonical instant profile moves too. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` validated a stored schedule instant with its own regular expression and round trip; the shared `fourDigitYearEpoch` owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: `packages/session/session-format-v0-to-v1/tests/validation.spec.ts` accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (`0000`, `2026-02-30`, a four-digit fraction, a numeric offset, surrounding whitespace). + The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads. Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. @@ -48,4 +50,4 @@ A value type publishes what its consumers call, so the span exposes the construc The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built. -Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch. +Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own offset reading for display, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. Five time sites keep their own code, each for a different reason. `packages/experimental/tool-session-query/src/input.ts` parses a wider profile than the shared entry — seconds optional, any number of fractional digits, and the digits below the millisecond kept so two instants can be compared exactly — so substituting the shared parser would narrow what it accepts, and moving that profile into the library is a design step of its own. The `Date.parse(record.scheduledAt)` reads in `dsh-schedule` parse a value the write path already validated, so a discriminated decode would add a rejection branch that cannot occur in practice and change what a corrupt record reports. The `everySeconds`, `afterSeconds`, and `refreshMarginSeconds` multiplications each sit beside a validation that raises the package's own error, so routing them through a constructor that throws its own type would change the failure a caller sees. The UTC offset a client reports is computed in `packages/client/ui-settings-account/src/client/client-metadata.ts` instead of shared: the client bundle admits a cross-plugin value import only for the packages it lists as inline-safe, `dsh-util-time` is not one of them, and it imports the `Temporal` polyfill at module scope, so inlining it would ship that polyfill to the browser. The Desktop host computes the same offset from the shared library, where no such rule applies. `dsh-workspace` writes its record timestamps with the platform's `toISOString`, which is the canonical rendering for every representable epoch: the extended-year and fractional-truncation cases a shared writer would refuse are unreachable from `Date.now()` and from a `SessionHeader` epoch, so adopting the shared formatter there would add a module and a dependency without changing a byte. diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md index 838f6b9d4b..72d4e6b12b 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -20,6 +20,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec `Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。 +最后一份规范时刻剖面副本也一并迁移。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 原本用自己的一段正则与往返比对校验持久记录里的调度时刻;共享的 `fourDigitYearEpoch` 拥有同一剖面,因此该迁移改为询问它,并保留自己的错误类型与文案。由于这个迁移要读取已发布的日志,其接受集是兼容性承诺:`packages/session/session-format-v0-to-v1/tests/validation.spec.ts` 接受四位年份两端,并拒绝只靠正则会放进来的每种拼写(`0000`、`2026-02-30`、四位小数、数字偏移、首尾空白)。 + 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。 可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 @@ -48,4 +50,4 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。 -本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。 +本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与用于展示的偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。有五处时间代码各自保留实现,原因各不相同。`packages/experimental/tool-session-query/src/input.ts` 解析的剖面比共享入口更宽——秒可省略、小数位不限、并保留下毫秒数字以便精确比较两个时刻——因此换成共享解析器会收窄它接受的输入,而把那条剖面搬进本库是另一项独立的设计。`dsh-schedule` 里的 `Date.parse(record.scheduledAt)` 读的是写入路径已经校验过的值,换成带判别值的解码会在每个读取点新增一个实际不可能发生的拒绝分支,并改变损坏记录的报告方式。`everySeconds`、`afterSeconds` 与 `refreshMarginSeconds` 三处乘法旁边都紧跟一条抛本包自有错误的校验,因此把它们改走会抛自身错误类型的构造函数,会改变调用方看到的失败。客户端上报的 UTC 偏移由 `packages/client/ui-settings-account/src/client/client-metadata.ts` 自行计算而不共享:客户端 bundle 只允许对列入 inline-safe 的包做跨插件值导入,`dsh-util-time` 不在其中,而且它在模块顶层静态导入 `Temporal` polyfill,内联它等于把该 polyfill 打进浏览器;Desktop 的宿主侧则用共享库计算同一个偏移,那里没有这条限制。`dsh-workspace` 用平台的 `toISOString` 写记录时间戳,对每个可表示的纪元那已是规范渲染:共享写入器会拒绝的扩展年份与小数截断两种情形,从 `Date.now()` 与 `SessionHeader` 的纪元出发都不可达,因此在那边采用共享格式化器只会增加一个模块与一条依赖,却不会改变任何字节。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml new file mode 100644 index 0000000000..4a2fe2ba9d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-web-search-offered-by-provider-readiness.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready: + en: 9163af91ea0e030d + zh: 92e44043b999d32f +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem: + en: 6fac3c73a4415c83 + zh: f65b61cc1705348b +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision: + en: 458416ed7e47f644 + zh: 8b4dd820228e9dfd +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered: + en: b731254983be656d + zh: 5dee2b09f7faa537 +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences: + en: 798faeaa0b406eac + zh: 97bbfdaebc84b1d4 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md new file mode 100644 index 0000000000..c3e74990d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -0,0 +1,51 @@ +# Agent Note: Offer web_search and web_fetch only while the selected provider is ready + +Status: implemented + +English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md) + +## Problem + +`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed. + +## Decision + +Each role of the web capability seam owns one part of a per-step check: + +- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`. +- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section. +- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set. + +The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway. + +Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none. + +The request header records the assembled tools, so the agent loop announces each change through tool-registry updates. A route switch, a stored key, or a removed key changes the next request; DeepSeek routes load a returning tool as a deferred addition, and routes without tool-update support receive the complete tool list. + +## Alternatives considered + +**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed. + +**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation. + +**Read the route from the Session's latest `request/context` event instead of passing it.** That event records the previous request, so a new Session's first request and the first request after a model switch would be decided for the wrong route or for none. + +**Register or unregister `web_search` in `dsh-tool-web`.** Registration is shared by every Session of an agent preset, while credentials depend on each request's route. Unregistering would also make `toolOrder` entries and subagent `toolFilter` restrictions that name `web_search` fail. + +**Remove `web_search` from the assembled request.** A `system-prompt/assemble` listener could drop the declaration and its section, but the tool would stay executable, so a model that repeats an earlier call would still run it, and PTC programs would still bind it. Prompt assembly also no longer carries tool declarations. + +**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case. + +**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find. + +**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint. + +**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt. + +**Probe the key or endpoint before offering the tool.** A network request per model request adds latency and cost; the user controls configuration, not its validity at that moment. + +## Consequences + +`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package. + +Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md new file mode 100644 index 0000000000..9c4b0f6948 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 仅在选中的提供方就绪时提供 web_search 与 web_fetch + +Status: implemented + +[English](2026-09-27-web-search-offered-by-provider-readiness.md) | 中文 + +## Problem + +`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。 + +## Decision + +web 能力 seam 的每个角色各负责逐步骤检查的一部分: + +- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。 +- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。 +- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。 + +路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。 + +只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 + +请求头记录组装后的工具,因此 agent loop(智能体循环)通过工具注册表更新宣告每次变化。路由切换、存储密钥或删除密钥都会改变下一个请求;DeepSeek 路由以延迟加载的新增方式载入重新提供的工具,不支持工具更新的路由则接收完整工具列表。 + +## Alternatives considered + +**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。 + +**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。 + +**从会话最近的 `request/context` 事件读取路由,而不是传入路由。** 该事件记录的是上一个请求,因此新会话的首个请求,以及模型切换后的首个请求,会按错误的路由判断,或根本没有路由可用。 + +**在 `dsh-tool-web` 中注册或注销 `web_search`。** 注册由同一 agent preset 的所有会话共享,而凭据取决于每个请求的路由。注销还会使点名 `web_search` 的 `toolOrder` 条目和 subagent `toolFilter` 限制失败。 + +**从组装好的请求中移除 `web_search`。** `system-prompt/assemble` 监听器可以删掉该声明及其段落,但工具仍可执行,因此模型重复先前的调用时仍会运行它,PTC 程序也仍能绑定它。提示词组装也已不再携带工具声明。 + +**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。 + +**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 + +**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。 + +**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。 + +**提供工具前探测密钥或端点。** 每个模型请求都发起一次网络请求会增加延迟与成本;用户控制的是配置本身,而不是它在那一刻是否有效。 + +## Consequences + +`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。 + +单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..a644575d18 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -27,17 +27,17 @@ en: a01ac57fc71a84f0 zh: 3e488ef3da5e4b8c /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control: - en: a55516339b0d49dd - zh: 3281df9ea7878fc0 + en: 725a2d4145c0a75e + zh: b98a64532cadeaba /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac: en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: 625b20552962e268 - zh: 1136c0fd7cdc6901 + en: d50ae22978165c57 + zh: 8c1fcfb7dc2d3367 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: - en: ec1677d8e338e115 - zh: 6bedd163f208eaa9 + en: 056a141b31270cba + zh: 3f6caafb3a13c192 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: en: e57f396239ea9f56 zh: ddb26278b1521ceb @@ -45,8 +45,8 @@ en: 3c911f57f0f6ca4b zh: 1509781eeda4c922 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: - en: 6c58dc8777e107ef - zh: bc14605c96e4d8f3 + en: 2d97a251cc4c9db0 + zh: df072d5a96a9e82f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: en: 5fe34ee5ec31942a zh: ef39b020b0539b8f @@ -54,8 +54,8 @@ en: 7865e159528e2d30 zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: - en: 41d7ac1faf2448d7 - zh: 6d523895a9abae68 + en: a928a63e8c36177c + zh: c943c1fdc8c4bb45 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: en: 7be28cd7e19e5377 zh: 30b5aee957271d6c @@ -75,14 +75,14 @@ en: 305ec45a7cd85ed7 zh: d2cd4209a56b1d0c /agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered: - en: 130df2981ba550df - zh: e9a0a89af6fb50a3 + en: 60da38bca6e9c3d6 + zh: 61ea578fe8185b61 /agent-note-peer-principals-and-method-level-remote-access-control/consequences: en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 048067eb02e6bf8a - zh: 12508b59f6b206b8 + en: d7d0909974609247 + zh: 6ebf20714f8d545b /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..6b1cc15750 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is ### Method-level access control -Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. +Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field or resolver, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. #### Correspondence with RBAC @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,11 +213,11 @@ export function RemoteScope | Rule | Content | |---|---| -| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | +| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | -| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | -| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | -| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | +| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults | +| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid | +| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization | | Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | | Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | | Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | @@ -228,7 +228,7 @@ export function RemoteScope #### Endpoint resources -The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field: +The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults: | Endpoint | Resource | Resolution source | Filtering inside the method | |---|---|---|---| @@ -241,14 +241,14 @@ The table lists the endpoints #3854 viewers use and where their resources come f | `llm/listProviders` | `global` | no resource | no | | `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | | `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | -| `session/attachment` | session: `args.request.sessionId` | default field | no | -| `skills/list` | session: `args.request.sessionId` | default field | no | -| `session/fork` | session: `args.request.sessionId` | default field | no | -| `session/prompt` | session: `args.request.sessionId` | default field | no | -| `session/cancel` | session: `args.request.sessionId` | default field | no | -| `session/projections` | session: `args.request.sessionId` | default field | no | -| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no | -| `session/follow` | same as above | `resources` | no | +| `session/attachment` | session: `args.request.sessionId` | default resolver | no | +| `skills/list` | session: `args.request.sessionId` | default resolver | no | +| `session/fork` | session: `args.request.sessionId` | default resolver | no | +| `session/prompt` | session: `args.request.sessionId` | default resolver | no | +| `session/cancel` | session: `args.request.sessionId` | default resolver | no | +| `session/projections` | session: `args.request.sessionId` | default resolver | no | +| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no | +| `session/follow` | same as above | default resolver | no | | `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. @@ -392,7 +392,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer | 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | | 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) | | 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | -| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then the registered default field or resolver; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | | 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | Behavior | Detail | |---|---| -| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts | | Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | | Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | | Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | @@ -600,7 +600,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R **Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it. -**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field. +**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field or resolver. **Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation. @@ -635,10 +635,10 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | | Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | | Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | -| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | -| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | +| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, with undeclared fields stripped only by compiled codecs, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | +| Resource registry | `typert.resources` registers one default field or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | -| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | | List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..00e418b8c7 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal ### 方法级访问控制 -资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 +资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 #### 与 RBAC 的对应 @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,11 +213,11 @@ export function RemoteScope | 规则 | 内容 | |---|---| -| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | +| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | -| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | -| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | -| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | +| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 | +| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 | +| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 | | 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | | 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | | 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | @@ -228,7 +228,7 @@ export function RemoteScope #### 端点的资源 -下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段: +下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则: | 端点 | 资源 | 解析来源 | 方法内过滤 | |---|---|---|---| @@ -241,14 +241,14 @@ export function RemoteScope | `llm/listProviders` | `global` | 无资源 | 否 | | `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | | `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | -| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 | -| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 | -| `session/follow` | 同上 | `resources` | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 | +| `session/follow` | 同上 | 默认 resolver | 否 | | `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 @@ -392,7 +392,7 @@ const childPrompt = child => ({ action, args }) => | 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | | 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) | | 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | -| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | | 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | 鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | 行为 | 说明 | |---|---| -| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 | | 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | | 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | | 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | @@ -600,7 +600,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot **拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。 -**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。 +**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。 **按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。 @@ -635,10 +635,10 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | | policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | | 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | -| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | -| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | +| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | +| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | -| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | | 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 62f41f9e6f..85cccf7afa 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 0cdd295d5f1872ed - zh: fe03ec9f7df94a4a + en: 0f45528ea6c5988c + zh: 9bde9df417ddb1ce /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index e43121c8c8..d81d964f8a 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -12,7 +12,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route falls back to the real system pointer. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). -Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 1000 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. +Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. `LlmAdapter.imageRequestTarget` exposes the same dimensions and byte target used by image serialization. DeepSeek and pi-ai share their existing projection functions with this method. Other adapters preserve normalized images unless they declare resizing. Screenshot bytes returned to programs match the request image; its durable source is admitted and stored once. The native PNG metadata is checked against decoded source dimensions before any input mapping is published. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 51beb3967f..2ef7ac9e44 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -12,7 +12,7 @@ Status: implemented 原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 -面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~1000 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 +面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~999 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 `LlmAdapter.imageRequestTarget` 提供与图片序列化相同的尺寸和字节目标。DeepSeek 和 pi-ai 在该方法中复用已有投影函数。其他适配器除非声明缩放,否则保留归一化图片。返回程序的截图字节与模型请求图片一致,持久原图只接入并保存一次。发布输入映射前,原生 PNG 元数据必须与解码后的原图尺寸一致。 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml index 3ae45a7509..b04f873a24 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml @@ -12,8 +12,8 @@ en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment: - en: caf1ca77192dcb20 - zh: d663e2cbb05fe4fc + en: 9b957e37f290abaf + zh: 06a4599c8fe449ad /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker: en: 37d8e809eaa21d25 zh: 8c7c31bd6f45c823 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md index 47b2866402..71509500e5 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md @@ -23,7 +23,7 @@ Measured: with no bundle enabled, the two compositions differ in only one of 188 The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it: - **packer** - - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses. + - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from `@deepseek-ai/dsh-official-bundles`, the same source the Plugins page uses. - The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`. - The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots. - The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image. diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md index 018992e103..5c9b5bf233 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md @@ -23,7 +23,7 @@ Status: implemented 正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路: - **packer** - - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。 + - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 `@deepseek-ai/dsh-official-bundles` 读,与插件页同一来源。 - 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。 - 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。 - 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml index 0c7cce4b83..ca846c7118 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md /agent-note-agent-teams-teammates-are-subagents: - en: d74a90e6568d278a - zh: 538f5fa098e2e925 + en: 51e2fc51a6e1622a + zh: 7e09ea6292e5921d /agent-note-agent-teams-teammates-are-subagents/problem: en: ca2848e3bbc3a009 zh: 0551e32ff6d187d0 /agent-note-agent-teams-teammates-are-subagents/decision: - en: 6e1d8ea634e8e3fc - zh: c9af24066de755a5 + en: 9da65aee28d19268 + zh: 20191b1ec59697b1 /agent-note-agent-teams-teammates-are-subagents/alternatives-considered: en: ef0cf648ff54784f zh: 2a0b3745a54e7072 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md index 0c6147d7a2..426750813c 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-10-15-agent-teams-as-subagents.zh.md) -The name-free model interface below is superseded by [hierarchical agent names](2026-10-17-hierarchical-agent-names.md). Session identity, lineage authority, and Team reuse remain current. The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. +The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. ## Problem @@ -18,7 +18,7 @@ Use one subagent identity: the Session id. The existing `description` tool argum Every root Agent and its descendants form the Team identified by the root Session id. Team creates no agents and contributes task tools, task policy, and required Team identity. Ordinary subagent controls supply discovery, messaging, interruption, and inbox waiting. Parent and fork children receive identical tool schemas and shared policies. -Model-facing task ownership uses `ownerName`; host ownership retains `ownerId`; root reassignment accepts itself or any reachable continuable descendant. Historical roster records remain decode-only. Team persists task changes and signals active waits after commit without starting idle turns. Historical `team/message/*` records remain readable but are never delivered or acknowledged. +Task ownership uses `ownerId`; root reassignment accepts itself or any reachable continuable descendant. Historical roster records remain decode-only. Team persists task changes and signals active waits after commit without starting idle turns. Historical `team/message/*` records remain readable but are never delivered or acknowledged. This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) lists removed tools, methods, and configuration. diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md index 45c2d8ef22..dfd80205de 100644 --- a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-10-15-agent-teams-as-subagents.md) | 中文 -下述不使用名字的模型接口由[分层 agent 名字](2026-10-17-hierarchical-agent-names.zh.md)取代。Session 身份、谱系权限和 Team 复用仍然有效。邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 +邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 ## Problem @@ -18,7 +18,7 @@ Status: implemented 每个根 Agent 及其后代组成以根 Session id 标识的 Team。Team 不创建 agent,提供任务工具、任务策略和必需 Team 身份。普通 subagent 控制工具提供发现、通信、中断和收件箱等待。父级和 fork 子级接收相同工具 schema 和共享策略。 -面向模型的任务归属使用 `ownerName`;host 归属保留 `ownerId`;根重新分配可指定自身或任意可达的可继续执行后代。历史名册记录仅供解码。Team 持久化任务变更,并在提交后通知当前等待,不启动空闲轮次。历史 `team/message/*` 记录仍可读取,但绝不再投递或确认。 +任务归属使用 `ownerId`;根重新分配可指定自身或任意可达的可继续执行后代。历史名册记录仅供解码。Team 持久化任务变更,并在提交后通知当前等待,不启动空闲轮次。历史 `team/message/*` 记录仍可读取,但绝不再投递或确认。 本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml index 89cf05fabb..ee52c30615 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml @@ -9,8 +9,8 @@ en: c583c3cdb894e417 zh: 6edc0354b79f4f26 /agent-note-communication-follows-the-subagent-tree/decision: - en: d613dd690cb06596 - zh: fab70b2107e33054 + en: da35a75e069fa1e4 + zh: 7edf62ad66ab04ec /agent-note-communication-follows-the-subagent-tree/alternatives-considered: en: 470f9cf8c3e32af9 zh: 73bbe29767c5d02a diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md index 1fad34c523..30dee6781a 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md @@ -10,13 +10,13 @@ A Team shares one task board across the root's entire subagent tree. Restricting ## Decision -`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent paths and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. +`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent ids and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. `send_message` accepts another root or continuable member of the same tree. The sender must be the exact live root or a resident continuable subagent. Cross-branch delivery verifies the target descriptor and creation catalogs, then restores only the target under the Team root. Final inbox admission rechecks the sender and root. Cancellation or rejection before acceptance releases the new target instance; accepted input follows normal Agent persistence. The existing Agent registry records runtime ownership by the Team root. Creation parent ids and catalogs preserve ancestry for discovery and interrupt authority. A member finishing or being released affects only its own instance; Team-root shutdown joins all startup and cleanup. This removes ancestor restoration, parent retention, and recursive disposal without adding a Team runtime registry. Saved target presets and permission events supply restoration configuration. -This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Model tools use [hierarchical agent paths](2026-10-17-hierarchical-agent-names.md); host delivery and authorization use Session ids; ordinary Session forks lead independent Teams. +This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members use Session ids for addressing; ordinary Session forks lead independent Teams. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md index cfae0bbc1d..58d075e9a6 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md @@ -10,13 +10,13 @@ Status: implemented ## Decision -`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级路径和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 +`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级 id 和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 `send_message` 接受同一棵树中的其他根或可继续成员。发送者必须是确切在线根或驻留的可继续子代理。跨分支投递验证目标描述符和创建目录后,仅在 Team 根下恢复目标。最终收件箱准入重新检查发送者与根。接受前的取消或拒绝释放新建目标实例;已接受输入遵循普通 Agent 持久化。 现有 Agent 注册表记录 Team 根对运行时实例的所有权。创建父级 id 和目录保留用于发现及中断授权的祖先关系。成员完成或释放只影响自身实例;关闭 Team 根会等待全部启动和清理。这删除了祖先恢复、父级保留和递归释放,无需增加 Team 运行时注册表。目标保存的 preset 和权限事件提供恢复配置。 -本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。模型工具使用[分层 agent 路径](2026-10-17-hierarchical-agent-names.zh.md);host 投递与授权使用 Session id;普通 Session 分叉领导独立 Team。 +本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用 Session id 寻址;普通 Session 分叉领导独立 Team。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml deleted file mode 100644 index 9714a56a7e..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller: - en: 9105f8148e107f43 - zh: a160e87bb0605da4 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem: - en: 5071232d9743096c - zh: b553a626538b3466 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision: - en: 1b61d351c4d2bdf1 - zh: d6f8180b30d97b34 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered: - en: acc465cfe02b624c - zh: db1d06574f647130 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences: - en: 9d8486586d044709 - zh: 24c7249421fd2541 diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md deleted file mode 100644 index 6d1c10ad9f..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Delivering messages from outside an Agent through the Agent controller - -Status: implemented - -English | [中文](2026-10-17-agent-controller-message-delivery.zh.md) - -## Problem - -Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client. - -## Decision - -The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged. - -| Part | Contract | -|---|---| -| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox | -| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake | -| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails | -| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it | -| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled | -| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox | - -Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message. - -`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice. - -The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check. - -Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids. - -The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile. - -## Alternatives considered - -- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract. -- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it. -- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts. -- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing. -- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it. -- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`. - -The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it. - -## Consequences - -- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed. -- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft. -- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced. -- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt. -- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point. diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md deleted file mode 100644 index 5f5d247f7b..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息 - -Status: implemented - -[English](2026-10-17-agent-controller-message-delivery.md) | 中文 - -## 问题 - -插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。 - -## 决策 - -core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。 - -| 部分 | 约定 | -|---|---| -| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id | -| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 | -| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 | -| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 | -| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 | -| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 | - -只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。 - -`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。 - -Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。 - -请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。 - -Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。 - -## 备选方案 - -- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。 -- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。 -- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。 -- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。 -- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。 -- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。 - -PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。 - -## 影响 - -- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。 -- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。 -- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。 -- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。 -- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.i18n.yaml deleted file mode 100644 index 3529f5316f..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-10-17-hierarchical-agent-names.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.md -/agent-note-hierarchical-agent-names: - en: 4a1223d881f03a6f - zh: acced71e5ee769b2 -/agent-note-hierarchical-agent-names/problem: - en: 78696f33320218ec - zh: 13d8aa85ef049e6e -/agent-note-hierarchical-agent-names/decision: - en: fca6ce3d6dcbf86e - zh: b6d0405e91ddcf90 -/agent-note-hierarchical-agent-names/alternatives-considered: - en: 1447580f9c1f10e5 - zh: 147e2cc7062af05c -/agent-note-hierarchical-agent-names/consequences: - en: 691ef7ee95e422cc - zh: 20eb55b79e416eb8 -/agent-note-hierarchical-agent-names/testing: - en: 8cff7c7e8b903b20 - zh: 8e20b00864f8f3d8 diff --git a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.md b/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.md deleted file mode 100644 index 57968fa9b4..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Hierarchical agent names - -Status: implemented - -English | [中文](2026-10-17-hierarchical-agent-names.zh.md) - -## Problem - -Session ids are durable execution identities but difficult to read in model coordination. Descriptions may repeat. Flat tree-wide names require every parent to coordinate reservations and publish descendant assignments in the root, introducing a second Session write and incomplete-registration recovery. - -## Decision - -Keep Session ids for persistence and authorization. Each child has an immutable name segment unique among its direct siblings. Model tools return and accept full paths rooted at `lead`, such as `lead/reviewer` and `lead/explore/alice`. Parent catalogs retain the segment and relationship, including external caller-delivery work. Tools and host callers can omit the segment: runtime reserves the first unused `agent-N`, starting at `agent-1`, across committed and pending siblings before any asynchronous preparation. No persisted counter or descriptor copy is needed. Path lookup reads only intermediate parent catalogs. Temporary reservations belong to the exact live parent and reject concurrent sibling duplicates before provider dispatch. A parent catalog append commits creation, and committed names survive settlement and restart. - -This supersedes the name-free interface in [Agent Teams as subagents](2026-10-15-agent-teams-as-subagents.md). Its lineage authority and shared subagent lifecycle remain current. Resolution grants no messaging, interruption, or assignment authority. Historical unnamed children use `legacy:` segments; historical Team members without a catalog grant no assignment authority. - -## Alternatives considered - -**Flat tree-wide names.** Short addresses are convenient and root registration avoids intermediate catalog reads. That convenience requires shared name admission, a root assignment record, and recovery when the parent write succeeds but root registration fails. Paths give every caller the same unambiguous address while reusing parent-owned records. Reintroducing a flat namespace would require a demonstrated need for globally unique short aliases that outweighs these costs. - -**Recyclable names.** Reuse could redirect delayed work after the original agent settles. Retaining assignments keeps recipient identity stable. - -**Checking only before asynchronous startup.** Two concurrent sibling creations could both pass. Parent-local temporary reservations prevent duplicate external work before it starts; failed creation releases the reservation. - -## Consequences - -Creation accepts an optional segment; tool results, child guidance, message prefixes, task owners, and settlement notices use full paths. Different parents and independent roots can reuse segments. Cold path lookup depends on intermediate catalogs, but not unrelated branches. A failed parent catalog append can leave a local descriptor; a child cannot resume without its parent catalog entry. Internal Session and task-owner ids stay stable. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) describes caller changes. - -## Testing - -Focused tests cover automatic allocation, pending-name release, restart stability, same-name children in separate branches, concurrent sibling rejection before dispatch, failed-start release, parent catalog failure, restoration, fork isolation, legacy paths, and ambiguous persisted names. Recorded Headless and SDK sessions cover creation, nested paths, messages, listing, and completion notices; Team tests reject historical membership as assignment or message authority. diff --git a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.zh.md b/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.zh.md deleted file mode 100644 index 0876aeb6e7..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-hierarchical-agent-names.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 分层 agent 名字 - -Status: implemented - -[English](2026-10-17-hierarchical-agent-names.md) | 中文 - -## 问题 - -Session id 是持久执行身份,但不便于阅读模型协作内容。描述可以重复。树内全局短名要求所有父级协调名字预留,并将后代分配发布到根,引入第二次 Session 写入和不完整登记恢复。 - -## 决策 - -保留 Session id 用于持久化和授权。每个子级有一个不可变的名字路径段,只在直接同级中唯一。模型工具返回并接受以 `lead` 为根的完整路径,如 `lead/reviewer` 和 `lead/explore/alice`。父级 catalog 保留路径段及关系,包括 caller 交付的外部工作。工具和 host 调用方均可省略路径段:runtime 在任何异步准备之前,从 `agent-1` 开始预留已提交与临时预留的同级中第一个未占用的 `agent-N`。无需持久计数器或 descriptor 副本。路径查找只读取沿途父级 catalog。临时预留属于确切在线的父级,在 provider 派发前拒绝并发同级重名。父级 catalog 追加提交创建,已提交名字在执行结束和重启后保留。 - -本记录取代 [Agent Teams 作为 subagent](2026-10-15-agent-teams-as-subagents.zh.md) 中不使用名字的接口。其谱系权限与共享 subagent 生命周期仍然有效。解析不授予消息、中断或分配权限。历史无名子级使用 `legacy:` 路径段;没有 catalog 的历史 Team 成员不授予分配权限。 - -## 考虑的替代方案 - -**树内全局短名。** 短地址便于使用,根登记避免读取中间 catalog。但这要求共享名字准入、根分配记录,以及父级写入成功但根登记失败后的恢复。路径复用父级已有记录,仍为所有调用方提供相同的无歧义地址。重新引入全局短名空间需要证明确有足以抵偿这些成本的短别名需求。 - -**可回收名字。** 执行结束后复用名字,可能让延迟工作转向另一个接收方。保留分配可维持接收方身份稳定。 - -**只在异步启动前检查。** 两个并发同级创建都可能通过检查。父级局部临时预留在外部工作开始前阻止重复派发;创建失败会释放预留。 - -## 影响 - -创建接受可选路径段;工具结果、子级指引、消息前缀、任务负责人和完成通知使用完整路径。不同父级和独立根可复用路径段。冷路径查找依赖沿途中间 catalog,不依赖无关分支。父级 catalog 追加失败可能留下本地 descriptor;子级缺少父级 catalog 条目时无法恢复。内部 Session 和任务负责人 id 保持稳定。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md) 说明调用方变更。 - -## 测试 - -定向测试覆盖自动分配、临时名字释放、重启稳定性、不同分支同名子级、派发前拒绝并发同级重名、启动失败释放、父级 catalog 失败、恢复、fork 隔离、legacy 路径和有歧义的持久化名字。录制的 Headless 与 SDK 会话覆盖创建、嵌套路径、消息、列表和完成通知;Team 测试拒绝将历史成员关系作为分配或消息权限。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml new file mode 100644 index 0000000000..3ebb632746 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md +/agent-note-file-drag-ownership-outside-the-application-layout: + en: 951575b08912a949 + zh: f33e8e6bcb9240bd +/agent-note-file-drag-ownership-outside-the-application-layout/problem: + en: 557edc1db9bee8d8 + zh: 20f47c78c8b0078b +/agent-note-file-drag-ownership-outside-the-application-layout/decision: + en: 1dfea60901d6e6ae + zh: e4fc534dbe01b0d5 +/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered: + en: 571eabab132ce4c1 + zh: b5f8207015aeb3be +/agent-note-file-drag-ownership-outside-the-application-layout/consequences: + en: 56ebb7c25911fd61 + zh: 88120d2af74785ef diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md new file mode 100644 index 0000000000..5d99b5815a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md @@ -0,0 +1,25 @@ +# Agent Note: File drag ownership outside the application layout + +Status: implemented + +English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md) + +## Problem + +Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor. + +## Decision + +The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files. + +## Alternatives considered + +**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner. + +**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes. + +**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging. + +## Consequences + +One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures. diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md new file mode 100644 index 0000000000..0e361717a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 应用布局之外的文件拖放归属 + +Status: implemented + +[English](2026-10-19-file-drag-plugin-ownership.md) | 中文 + +## Problem + +图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。 + +## Decision + +[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。 + +## Alternatives considered + +**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。 + +**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。 + +**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。 + +## Consequences + +一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml new file mode 100644 index 0000000000..3e9350f2b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-pi-ai-system-reminder-fallback.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md +/agent-note-pi-ai-system-reminder-fallback: + en: f43b5c6974289180 + zh: abc0a86b1d137189 +/agent-note-pi-ai-system-reminder-fallback/problem: + en: 5247278483ca4e7a + zh: ca711671a43fae76 +/agent-note-pi-ai-system-reminder-fallback/decision: + en: 604eb72704c73328 + zh: 6661d1065f65ecf9 +/agent-note-pi-ai-system-reminder-fallback/alternatives-considered: + en: 0e2aaf897587ebb0 + zh: 0a7f9a9cb7ddffaf +/agent-note-pi-ai-system-reminder-fallback/consequences: + en: 8a0c53289a04c64a + zh: 591e602eb2036a1a diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md new file mode 100644 index 0000000000..458fdb83ca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai system reminder fallback + +Status: implemented + +English | [中文](2026-10-19-pi-ai-system-reminder-fallback.zh.md) + +## Problem + +Pi-ai concatenates later system messages into the initial prompt for models without native mid-conversation system support. Later system messages supplied to the adapter therefore invalidate the preceding conversation cache prefix. + +## Decision + +The pi-ai adapter uses the prepared model's existing `supportsMidConvoSystemMessages` capability mapping. It preserves native system messages on capable models and converts each non-empty later system message into a user message wrapped in `` otherwise, before pi-ai receives the context. Leading system text remains in the initial prompt. Message order, repeated updates, and existing text-block concatenation remain intact. Durable messages retain their original roles. + +The fallback belongs to the adapter because it depends on the selected protocol capability. Prompt admission remains owned by the agent loop, which consolidates system-prompt updates on routes without native support; this fallback only handles later system messages present in the supplied request. + +## Alternatives considered + +**Let pi-ai concatenate updates.** Retains system-role priority but changes the initial prompt whenever later system text changes, defeating prefix reuse. + +**Advertise native support or send developer messages.** Misstates model capability or relies on another unsupported instruction role. The fallback uses ordinary user messages and does not change capability metadata. + +## Consequences + +The wrapper does not confer system-role authority, so reminders have user-role priority. Cache hits remain provider-dependent; tool changes and compaction can still invalidate prefixes. Both text-only and attachment-aware conversion apply the same fallback, and wire tests inspect the real pi-ai serializer output. diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md new file mode 100644 index 0000000000..02e9ca648f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai 系统提醒回退 + +Status: implemented + +[English](2026-10-19-pi-ai-system-reminder-fallback.md) | 中文 + +## 问题 + +对于不原生支持对话中系统消息的模型,pi-ai 会把后续系统消息拼接进初始提示词。因此,传给适配器的后续系统消息会使此前的对话缓存前缀失效。 + +## 决策 + +pi-ai 适配器使用已准备模型的现有 `supportsMidConvoSystemMessages` 能力映射。对于具备该能力的模型,适配器保留原生系统消息;否则,在 pi-ai 接收上下文之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。开头的系统文本仍位于初始提示词中。消息顺序、重复更新和现有文本块拼接方式均保持不变。持久消息保留原始角色。 + +回退由适配器负责,因为它取决于所选协议的能力。提示词纳入仍由 Agent loop 负责,它在不支持原生更新的路由上合并系统提示词更新;此回退仅处理传入请求中已有的后续系统消息。 + +## 考虑过的替代方案 + +**让 pi-ai 拼接更新。** 保留系统角色优先级,但后续系统文本每次变化都会改变初始提示词,破坏前缀复用。 + +**宣称原生支持或发送 developer 消息。** 错报模型能力,或依赖另一种不受支持的指令角色。回退使用普通用户消息,不改变能力元数据。 + +## 后果 + +包裹文本不会赋予系统角色权限,因此提醒具有用户角色优先级。缓存命中仍取决于提供方;工具变化和压缩仍可能使前缀失效。纯文本与附件感知转换使用相同的回退,线协议测试检查真实 pi-ai 序列化器的输出。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml new file mode 100644 index 0000000000..59918ce6a1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md +/agent-note-replaceable-product-icons-through-an-svg-sprite: + en: 2b527e550e071451 + zh: 7de256912270cae0 +/agent-note-replaceable-product-icons-through-an-svg-sprite/problem: + en: d55557fe41ab87c8 + zh: eea3f7d0c82561bf +/agent-note-replaceable-product-icons-through-an-svg-sprite/decision: + en: 6ddd80eae56693dd + zh: 4d654a76eb64c50a +/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered: + en: 2e25ece88bccdb22 + zh: bb24a28bef7ea7ce +/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences: + en: 0ad6db37bbb76ddb + zh: 0ca09d55a2167970 diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md new file mode 100644 index 0000000000..f4e228ee02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md @@ -0,0 +1,29 @@ +# Agent Note: Replaceable product icons through an SVG sprite + +Status: implemented + +English | [中文](2026-10-21-replaceable-icon-sprite.zh.md) + +## Problem + +Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components. + +## Decision + +Each product glyph weight is one sprite symbol, `dsw-icon--`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `` box with one `` of its symbol. ui-theme keeps one hidden sprite in the document whose `` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values. + +## Alternatives considered + +- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field. +- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot. +- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork. + +## Consequences + +- Icon DOM contains a `` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`. +- Without ui-theme, the sprite is absent and icons render nothing. +- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `-`; `` in another document, such as an iframe, cannot reach the sprite. +- Replacement markup is trusted like the registering plugin's code; `register` rejects `
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent - plugin_dsh_base_agent_controller["agent-controller
@deepseek-ai/dsh-agent-controller"] - cfg --> plugin_dsh_base_agent_controller plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] cfg --> plugin_dsh_base_plugin_package_inventory_deepseek plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] @@ -142,6 +140,8 @@ flowchart LR cfg --> plugin_dsh_base_command_compact plugin_dsh_base_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_dsh_base_subagent + plugin_dsh_base_subagent_names["subagent-names
@deepseek-ai/dsh-subagent-names"] + cfg --> plugin_dsh_base_subagent_names plugin_dsh_base_agent_team["agent-team
@deepseek-ai/dsh-agent-team"] cfg --> plugin_dsh_base_agent_team plugin_dsh_base_tool_agent_team["tool-agent-team
@deepseek-ai/dsh-tool-agent-team"] @@ -194,6 +194,10 @@ flowchart LR cfg --> plugin_dsh_base_mcp_resources plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"] cfg --> plugin_dsh_base_tools + plugin_dsh_base_tool_executor_native["tool-executor-native
@deepseek-ai/dsh-tool-executor-native"] + cfg --> plugin_dsh_base_tool_executor_native + plugin_dsh_base_tool_executor_ptc["tool-executor-ptc
@deepseek-ai/dsh-tool-executor-ptc"] + cfg --> plugin_dsh_base_tool_executor_ptc plugin_dsh_base_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] cfg --> plugin_dsh_base_system_prompt plugin_dsh_base_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] @@ -224,7 +228,6 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | -| `agent-controller` | `@deepseek-ai/dsh-agent-controller` | | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | @@ -275,6 +278,7 @@ flowchart LR | `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` | | `command-compact` | `@deepseek-ai/dsh-command-compact` | | `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-names` | `@deepseek-ai/dsh-subagent-names` | | `agent-team` | `@deepseek-ai/dsh-agent-team` | | `tool-agent-team` | `@deepseek-ai/dsh-tool-agent-team` | | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | @@ -301,6 +305,8 @@ flowchart LR | `tool-web` | `@deepseek-ai/dsh-tool-web` | | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` | | `tools` | `@deepseek-ai/dsh-tools` | +| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` | +| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | diff --git a/apps/cli/config/examples/ptc.yml b/apps/cli/config/examples/ptc.yml new file mode 100644 index 0000000000..d0c2cc888a --- /dev/null +++ b/apps/cli/config/examples/ptc.yml @@ -0,0 +1,5 @@ +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/apps/cli/package.json b/apps/cli/package.json index dab146cee4..77bf61847c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/dsh-persona": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-sdk-minimal": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", + "@deepseek-ai/dsh-subagent-names": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-filesystem": "workspace:*", "@deepseek-ai/dsh-skill-office": "workspace:*", @@ -105,7 +107,8 @@ "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-personalized-recommendations": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", @@ -154,6 +157,7 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index d7cbbd9ac6..ffd79b8daa 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 850434e8ca162221 - zh: 4893613f97a21eb8 + en: 149c5571acd51baf + zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: en: f40edcf903a51f3f zh: 8a9a915451047c8b diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 98b7197a92..a375450048 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. +Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 6441309f1c..642fa18bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -132,7 +132,7 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 86d0cc7525..baad6e32f5 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -1,7 +1,8 @@ /** Profile package management and explicit, exact-version compatibility approvals. */ import { runPluginCommand, runProfilePnpm, setProfileVersionExemption, type PackageOperationOptions } from '@deepseek-ai/dsh-plugin-manager/operations' import { INSTALL_ANCHOR } from './profile-boot.ts' -import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { initProfile, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES, DEFAULT_PROFILE_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index d4da86eeb1..da14308bd6 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -26,12 +26,12 @@ import { reportSkippedBundles, PluginPackages, PROFILE_PATCH_FILENAME, - PROFILE_TEMPLATES, resolveProfileDir, type ProfileContext, type Profile, type RuntimeResolution, } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index cd8b5aad9c..a97d158697 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -443,6 +443,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, SPAWN_TIMEOUT_MS + 30_000) it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => { + // On the official endpoint the route requests its model listing with this key; + // the loopback mock keeps the run offline and records any model call. + const server = await startMockLlmServer({ sequence: ['success'] }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) const pluginPath = join(home, 'plugin #100%.mjs') const marker = join(home, 'plugin-loaded') @@ -465,6 +468,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'built-sdk-profile-no-call', + DEEPSEEK_BASE_URL: server.baseURL, }, extendEnv: false, }) @@ -504,10 +508,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.signal, stderr).toBeUndefined() expect(result.exitCode, stderr).toBe(0) expect(stderr).toBe('') + expect(server.requests).toEqual([]) expect(readFileSync(marker, 'utf8')).toBe('loaded') } finally { child.kill('SIGKILL') await child + await server.close() rmSync(home, { recursive: true, force: true }) } }, SPAWN_TIMEOUT_MS + 30_000) @@ -1268,6 +1274,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index c84912a9f1..db8cc56e5d 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { fileURLToPath } from 'node:url' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,7 @@ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const overlayPath = process.argv[2] if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') @@ -31,6 +32,7 @@ try { id: agentId, options: {}, session, + model: unsupportedModel(), inbox: unsupportedInbox(), status: 'idle', send: () => {}, @@ -43,7 +45,7 @@ try { } const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, + { modelSelection: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const catalog = decision.kind === 'enter' @@ -51,7 +53,7 @@ try { && message.source.kind === 'skill-catalog')?.content : undefined const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('dsh-badge-snapshot'), name: 'skill', arguments: { name: 'dsh-badge' }, diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index 4c23211d81..0117077442 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -13,7 +13,8 @@ import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import WebSocket from 'ws' -import { initProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js') diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index f084cb29cb..b5a5eecbaf 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -1,4 +1,5 @@ /** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('loads the actual badge asset only while its bundle is selected', async () => { const owner = await agent() - const load = (callId: string) => ctx.tools.execute({ + const load = (callId: string) => executeToolFixture(ctx, { callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' }, agent: owner.agent, signal: new AbortController().signal, }) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts index bb757eb5e0..9185315cb1 100644 --- a/apps/cli/tests/plugin.spec.ts +++ b/apps/cli/tests/plugin.spec.ts @@ -3,7 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it, onTestFinished, vi } from 'vitest' -import { getDshRuntimeVersion, initProfile, PROFILE_TEMPLATES, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' +import { getDshRuntimeVersion, initProfile, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { runPluginCommand, runProfilePnpm } from '@deepseek-ai/dsh-plugin-manager/operations' import { runPlugin } from '../src/plugin.ts' diff --git a/apps/cli/tests/profile-initialization.spec.ts b/apps/cli/tests/profile-initialization.spec.ts index 7fa3ba6059..6c421c803e 100644 --- a/apps/cli/tests/profile-initialization.spec.ts +++ b/apps/cli/tests/profile-initialization.spec.ts @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url' import { initProfile, PROFILE_PATCH_FILENAME, - PROFILE_TEMPLATES, readProfileManifest, resolveProfileDir, writeProfileManifest, } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { afterEach, describe, expect, it, vi } from 'vitest' import { execa } from 'execa' import { initializeProfileFromDefault } from '../src/profile-boot.ts' diff --git a/apps/cli/tests/profile-mcp.spec.ts b/apps/cli/tests/profile-mcp.spec.ts index 2bd56d7ed3..b082d8316b 100644 --- a/apps/cli/tests/profile-mcp.spec.ts +++ b/apps/cli/tests/profile-mcp.spec.ts @@ -5,7 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { composeEntries, loadProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, loadProfile } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' const installAnchor = fileURLToPath(new URL('../package.json', import.meta.url)) const resourcePackage = '@deepseek-ai/dsh-mcp-resources' diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts index 9468f874cc..c97e093237 100644 --- a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -1,4 +1,4 @@ -/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */ import { once } from 'node:events' import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -6,10 +6,22 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' import { expect, it } from 'vitest' import { cleanupAcpExampleTest } from './cleanup.ts' +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const binScript = join(repoRoot, 'apps/cli/src/bin.ts') +const tsconfigPath = join(repoRoot, 'tsconfig.json') + +/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */ +function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[] { + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + return frames +} + it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) const token = 'acp-catalog-fixture-token' @@ -100,3 +112,48 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed') } }) + +it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const llm = await startMockLlmServer({ + sequence: ['tool_call_success', 'success'], + toolName: 'list_mcp_resources', + toolArguments: JSON.stringify({ server: 'fixture' }), + }) + let launched: LaunchedAcpTestAgent | undefined + try { + const configPath = join(cwd, 'mcp.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } }, + { id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + launched = launchAcpTestAgent({ + agent: { binScript, configPath, profile: 'acp', tsconfigPath }, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // The fixture server advertises only the tools capability. + const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts') + const created = await launched.client.newSession({ + cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }], + }) + await expect(launched.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'list the fixture resources' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' })) + await launched.client.closeSession({ sessionId: created.sessionId }) + await launched.close() + + expect(launched.child.exitCode).toBe(0) + expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success']) + expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()]) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed') + } +}) diff --git a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs index 5ca8db1ac6..4acea7354d 100644 --- a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs +++ b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs @@ -88,7 +88,7 @@ async function exercise(ctx, config) { // A transitive repository package the registry served, absent from npm at this version. const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version const provider = name.endsWith('-codex') ? 'codex' : 'claude-code' - const providerEnabled = ctx.subagents.list().includes(provider) + const providerEnabled = ctx.subagents.getProvider(provider) !== undefined const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name]) const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}` const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName) diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index ab06b02706..c54f84df0b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index bfcfc3ca75..d45d98dfd0 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -17,6 +17,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} {"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}} {"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}} +{"type":"model/selection","data":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index 6ea0d43a17..e747f996ad 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index f24d221b96..bc7c728199 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index a667224c5f..5792ae3774 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 2afe21d157..11ae2ec827 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index e6b850123f..36af85437a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,13 +16,13 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"lead [running] — team lead\nlead/legacy:{{sessionId}} [inactive] parent=lead depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — lead: root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index ceee1ba0b3..ea2320d83d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Delegated write probe","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash","agentReasoningEffort":"low"}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your name is \"lead/write-probe\". Your parent agent name is \"lead\". Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your name is \"lead/write-probe\". Your parent agent name is \"lead\". Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYour parent agent id is \"{{sessionId}}\". Use send_message({ agent_id: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:communication","text":"Your parent agent id is \"{{sessionId}}\". Use send_message({ agent_id: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 1cabc4a582..bfa0fd3ab0 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -16,20 +16,20 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe","name":"write-probe"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent lead/write-probe finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/write-probe finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent lead/write-probe"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent lead/write-probe finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/write-probe finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/replay.override.json b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/replay.override.json index e6da428e6c..1c0fe9ffad 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/replay.override.json +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/replay.override.json @@ -1 +1 @@ -[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "delegate-write", "name": "subagent", "argumentsDelta": "{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "delegate-write", "name": "subagent", "arguments": "{\"name\":\"write-probe\",\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The delegated child was denied by the sandbox. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The delegated child was denied by the sandbox. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] +[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "delegate-write", "name": "subagent", "argumentsDelta": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "delegate-write", "name": "subagent", "arguments": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The delegated child was denied by the sandbox. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The delegated child was denied by the sandbox. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index 180a9aa85f..d44ba911cd 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your name is \"lead/settlement-child\". Your parent agent name is \"lead\". Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your name is \"lead/settlement-child\". Your parent agent name is \"lead\". Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYour parent agent id is \"{{sessionId}}\". Use send_message({ name: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:communication","text":"Your parent agent id is \"{{sessionId}}\". Use send_message({ name: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/parent.override.json b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/parent.override.json index 1ce2e97ca7..91428bb387 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/parent.override.json +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/parent.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "start-child", "name": "subagent", "argumentsDelta": "{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "start-child", "name": "subagent", "arguments": "{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}" } }, + { "type": "tool-call-delta", "index": 0, "id": "start-child", "name": "subagent", "argumentsDelta": "{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "start-child", "name": "subagent", "arguments": "{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 17bf291b34..61bc45735b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,25 +4,26 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":4096}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"name\":\"settlement-child\",\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result","name":"settlement-child"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent lead/settlement-child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent lead/settlement-child finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/settlement-child finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent lead/settlement-child finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/settlement-child finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent-name/bound","seq":18,"time":0,"data":{"version":1,"childId":"{{sessionId}}","name":"lead/agent-1"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}\nname: lead/agent-1"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":21,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":25,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_SETTLEMENT_NOTICE","usage":{"inputTokens":30,"outputTokens":15}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index 5ed8464fb7..cbb82cb603 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index f52d5f06e9..3bd9c55f95 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index d4c74ac347..aa22a2d2f0 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -41,3 +41,9 @@ config: mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() + +# A search key reference no runner sets: the turn runs as a deployment without +# web search credentials, whatever the launching environment exports. +- id: web-search-deepseek + config: + apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 1e883d3f41..e25c65854a 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -72,7 +72,7 @@ function researcher(messages) { if (!names.includes('list_agents')) return toolChunks([{ name: 'list_agents', args: {} }]) const sibling = messages.filter(message => message.role === 'tool') .flatMap(message => message.content.filter(block => block.type === 'text').flatMap(block => block.text.split('\n'))) - .find(line => line.endsWith('— Own deterministic implementation.'))?.split(' ')[0] + .find(line => line.endsWith('Own deterministic implementation.'))?.split(' ')[0] if (sibling === undefined) throw new Error('list_agents did not expose the implementation sibling') if (!names.includes('team_task_create')) { return toolChunks([{ name: 'team_task_create', args: { @@ -93,7 +93,7 @@ function researcher(messages) { return textChunks('Research teammate complete.') } -function implementer(messages, parentName) { +function implementer(messages) { const names = calls(messages) const last = latestAssistantCalls(messages) const text = latestToolText(messages) @@ -134,7 +134,7 @@ function implementer(messages, parentName) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { name: parentName, message: 'Implementation complete and verified.' } }, + { name: 'send_message', args: { name: 'lead', message: 'Implementation complete and verified.' } }, ]) } return textChunks('Implementation teammate complete.') @@ -179,7 +179,7 @@ function lead(messages) { } if (last.includes('list_agents')) { const inactive = result.split('\n') - .filter(row => row.includes('— Own deterministic ') && row.includes('[inactive]')).length + .filter(row => row.includes('Own deterministic ') && row.includes('[inactive]')).length if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'list_agents' : 'wait_agent', args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) @@ -196,7 +196,6 @@ class TeamFixtureAdapter extends LlmAdapter { const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') const section = name => context?.source.sections?.find(candidate => candidate.name === name)?.text const role = section('team:identity') - const parentName = role?.match(/Team led by "([^"]+)"/u)?.[1] const task = initial?.content.filter(block => block.type === 'text').map(block => block.text).join('\n') if (!workflowChild && (!tools.includes('subagent') || !tools.includes('workflow') || !tools.includes('team_task_create'))) { throw new Error('Team profile is missing delegation, workflow, or task tools') @@ -204,7 +203,7 @@ class TeamFixtureAdapter extends LlmAdapter { let chunks if (workflowChild) chunks = textChunks('Fresh workflow child complete.') else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) - else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages, parentName) + else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages) else if (role?.includes('You are the Team Lead.')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index fb1c6251e4..5de08d039d 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => { '--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.', ], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => { const first = await runLoaderSmoke({ label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' }, }) const sessionId = parseJsonl(first.stdout)[0]?.sessionId if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity') @@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => { const second = await runLoaderSmoke({ label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' }, inspect: async (directory) => { const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -1254,11 +1254,11 @@ describe('headless stream-json snapshots', () => { "checkedAgents": true, "deliveredMessages": 0, "identities": [ - "Your name is "lead/implementer". You are a teammate in the Team led by "lead". + "You are a teammate in the Team led by "lead". To message your Team Lead, use send_message({ name: "lead", message: "..." }). Use list_agents({}) to find your teammates and their full names. To message another teammate, use send_message({ name: "", message: "..." }).", - "Your name is "lead/researcher". You are a teammate in the Team led by "lead". + "You are a teammate in the Team led by "lead". To message your Team Lead, use send_message({ name: "lead", message: "..." }). Use list_agents({}) to find your teammates and their full names. To message another teammate, use send_message({ name: "", message: "..." }).", diff --git a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts index 7869b81183..0272ff8752 100644 --- a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts @@ -60,7 +60,9 @@ describe('headless-agent keyless smoke', () => { }) expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) - expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search'])) + expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch'])) + // Search has no key in this composition, so the model request does not offer it. + expect(persistedToolNames).not.toContain('web_search') expect(persistedToolNames).not.toContain('str_replace_editor') }, PRODUCTION_PROFILE_TEST_TIMEOUT_MS) }) diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 9c4b58df8d..137941c43c 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -40,7 +40,7 @@ interface ResolutionEvidence { sourceToolsCjs: string | null sourceLeafCjs: string sourceLeafExplicitCjs: string - scheduler: boolean + executor: boolean modules: string[] } @@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void { const staleTools = join(root, 'stale-tools') const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools') await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, { - 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", + 'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) const sourceDir = join(sourcePackageDir, 'src') @@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void { 'probe.mjs': [ "import { createRequire } from 'node:module'", "import { getEnvironmentData } from 'node:worker_threads'", - "import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'", + "import Tools from '@deepseek-ai/dsh-tools'", `import { leaf } from '${leafName}'`, `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, @@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void { ' sourceToolsInstance: ctx.tools instanceof SourceTools,', ` profileToolsCjs: ${toolsCjsExpression},`, ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', - " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", + " executor: typeof ctx.tools.execute === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', ' }', @@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) - expect(evidence.scheduler).toBe(true) + expect(evidence.executor).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js' diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index cce567d62e..0eb7a187b0 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -1,3 +1,4 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -96,7 +97,7 @@ function runCode( signal: AbortSignal = testToolSignal, agent?: Agent, ): Promise { - return harness.tools.execute({ + return executeModelToolFixture(harness, { callId: ToolCallId(`keyless-code-${++keylessCall}`), name: RUN_CODE_NAME, arguments: { code, description: 'Run the e2e program' }, @@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) }; `, testToolSignal, agent)) - expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] }) + expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] }) }) }) diff --git a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts index 4988ef80fd..03f94856f4 100644 --- a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts @@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: 'native', DSH_CLI_MOCK_FAILURE: '0', }, input: '', diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index f4f70361cf..3aff474038 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -132,7 +132,7 @@ describe('parent-owned cold child catalog snapshot', () => { if (parent === undefined) throw new Error('missing persisted parent log') // Discovery reads child catalogs without requiring activation descriptors. - expect(parent).toContain(`legacy:${childId} [inactive] parent=lead depth=1 — descriptorless child`) + expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) expect(parent).not.toContain('[diagnostic: corrupt]') const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index adaf678278..d2f970bc65 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -11,6 +11,7 @@ import { fixtureContext, normalizeSessionSnapshot, normalizeSessionSnapshots, + REPLAY_CREDENTIAL_ENV, type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' @@ -104,6 +105,7 @@ describe('parent-only override inheritance snapshot', () => { binArgs: [configPath, task], tsconfigPath, env: { + ...REPLAY_CREDENTIAL_ENV, // The primary fixture path must exist for llm-replay's config guard; // the override sidecar fully replaces the derived parent script. DSH_SNAPSHOT_FILE: replayOverride, diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index b1b2c88128..ec5fe6ad94 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig, models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], } }, - { id: 'tools', config: { mode: 'native' } }, { id: 'session-log-deepseek', disabled: true }, { id: 'plugin-package-inventory-deepseek', disabled: true }, { insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] }, diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a65f11c29e..6f8add8c49 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { zstdDecompress } from 'node:zlib' +import { zstdDecompressSync } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js' import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' @@ -17,7 +17,6 @@ const launch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', }) -const decompress = promisify(zstdDecompress) function waitForLine( @@ -58,7 +57,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, - { label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true }, + { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, ])('$label', async (scenario) => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc @@ -92,6 +91,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, env: { @@ -99,7 +99,7 @@ describe('Python SDK dsh profile keyless smoke', () => { DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: ptc ? 'ptc' : 'native', + DSH_TOOLS_MODE: ptc ? 'native' : 'ptc', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), @@ -198,11 +198,30 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.dsh', 'sessions') const files = await readdir(sessionsRoot, { recursive: true }) - const log = files.find(file => file.endsWith('.jsonl.zstd')) + // Session logs are JSONL across one zstd frame per batch, header frame + // first. Decompress frames explicitly: a single call over concatenated + // frames returned only the first frame on Node 24.19 and returns every + // frame on 24.22, so neither behavior may back the assertion. + const logs: { compressed: Buffer; header: Record }[] = [] + for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) { + const compressed = await readFile(join(sessionsRoot, file)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + const first = scanZstdFrames(compressed).frames[0] + if (first === undefined) continue + const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0] + if (headerLine === undefined) continue + logs.push({ compressed, header: JSON.parse(headerLine) as Record }) + } + const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main') expect(log).toBeDefined() - const compressed = await readFile(join(sessionsRoot, log!)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) + const { frames, tornStart } = scanZstdFrames(log!.compressed) + expect(tornStart).toBeUndefined() + const records = frames + .flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n')) + .map(line => JSON.parse(line) as Record) + expect(records[0]).toMatchObject({ type: 'session', id: 'main' }) + expect(records.length).toBeGreaterThan(1) + expect(records.some(record => record.type === 'turn/end')).toBe(true) } finally { // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. child.kill('SIGKILL') diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index 2c90e5fc6e..7c8a94748d 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent ], { cwd: root, env: { PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), - DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', }, timeout: 110_000, killSignal: 'SIGKILL', reject: false, diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index 46b880cb78..f3efcf6dd1 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts index 3185cf4aa9..b13b0dd542 100644 --- a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -3,6 +3,7 @@ import { createServer } from 'node:http' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client' import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' import { expect, it, onTestFinished } from 'vitest' @@ -47,13 +48,14 @@ it.each([ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const seen: SdkUserQuestionRequest[] = [] const entered = Promise.withResolvers() const harness = state.harness = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000, + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], onUserQuestion: async (request, { signal }) => { expect(signal.aborted).toBe(false) seen.push(request) diff --git a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs index b13cc06455..3b0f7bdc32 100644 --- a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs +++ b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs @@ -1,4 +1,6 @@ /** Test-only IPC assertions over real Creator presets and profile management. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' + export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets'] export function apply(ctx, config) { @@ -30,7 +32,7 @@ export function apply(ctx, config) { try { const first = await make(`${phase}-first`) const before = names(first) - const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first, + const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first, callId: `${phase}-manage`, signal: new AbortController().signal }) ctx.permissionPresets.set(first.session, 'workspace-write') first.session.append('turn/start', { turn: 1 }) @@ -45,7 +47,7 @@ export function apply(ctx, config) { const second = await make(`${phase}-second`) const after = names(first) const other = names(second) - const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first, + const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first, callId: `${phase}-ping`, signal: new AbortController().signal }) const removed = phase === 'restart' ? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 7afc6143a9..6d1168b8df 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts' import { createRequire } from 'node:module' @@ -300,7 +301,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', @@ -344,7 +345,7 @@ describe('the shipped Web composition', () => { 'model', 'provider', 'reasoning_effort', ])) for (const agent of [disabled.agent, enabled.agent]) { - expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'name', 'prompt']) + expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt']) } } finally { await policy.dispose() @@ -629,7 +630,7 @@ describe('the shipped Web composition', () => { expect(scoped).toContain('project-proof') // The preset's own loader tool resolves the global-layer skill. - const loaded = await ctx.tools.execute({ + const loaded = await executeToolFixture(ctx, { callId: ToolCallId('preset-skills-load'), name: 'skill', arguments: { name: 'dsh-badge' }, @@ -711,7 +712,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) + expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort()) .toEqual([...installed].sort()) const expected = installed.map(toolName).sort() for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { @@ -723,7 +724,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac }) try { for (const product of installed) { - expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'name', 'prompt']) + expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'prompt']) } } finally { await handle.dispose() } expect(spawn).not.toHaveBeenCalled() @@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => { ])) } const signal = new AbortController().signal - const listed = await copyCtx.tools.execute({ + const listed = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-list'), name: 'cordis_inspect_list', arguments: {}, @@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. - const queried = await copyCtx.tools.execute({ + const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, @@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(queriedText).toContain(`"name": "${toolName}"`) } - // The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config, + // The `Config` provider reads the booted profile tree: the Agent loop declares a Config, // and the bootstrap include row is a carrier. The name filter keeps each page small. type ConfigRow = { id: string; patchId: string; name: string; status: string } type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } } const listConfigs = async (input: Record, callId: string): Promise => { - const page = await copyCtx.tools.execute({ + const page = await executeToolFixture(copyCtx, { callId: ToolCallId(callId), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input }, @@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] - expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' }) + expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes') expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' }) const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page') expect(firstPage.entries).toHaveLength(5) expect(firstPage.nextOffset).toBe(5) - const projected = await copyCtx.tools.execute({ + const projected = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-config'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } }, @@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => { const { data } = JSON.parse(resultText(projected)) as { data: Projected } expect(data.status).toBe('schema') expect(data.schema.$defs).toHaveProperty('loaderExpression') - expect(JSON.stringify(data.schema)).toContain('"mode"') + expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"') } finally { await copied.dispose() await shipped.dispose() @@ -954,7 +955,7 @@ describe('a delegated child', () => { ] as const for (const [name, args] of calls) { expect(toolNames(ctx, child.agent)).toContain(name) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, name, arguments: args, agent: child.agent, }) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1bcdb5bb1a..c52e4793e2 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/util/official-bundles" + }, { "path": "../../packages/boot/cmdline" }, diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index e79792f570..161391bbb2 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "private": true, "license": "MIT", "type": "module", @@ -30,6 +30,14 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~", - "@deepseek-ai/dsh-skill": "workspace:*" + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*" } } diff --git a/apps/desktop-host/src/file-drag.ts b/apps/desktop-host/src/file-drag.ts new file mode 100644 index 0000000000..473ab67e56 --- /dev/null +++ b/apps/desktop-host/src/file-drag.ts @@ -0,0 +1,64 @@ +/** Resolve desktop-only file references through the active Host filesystem and Session. */ +import { createHash } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' + +/** Private Host-to-main answer; never exposed to renderer JavaScript. */ +export interface LocalDragResource { + readonly path: string + readonly sha256: string + readonly size: number +} + +/** + * Authorize an unmodified local file through the composed filesystem's actual read operations. + * @param ctx - booted Desktop Host. + * @param address - Session-scoped resource reference. + * @param cancellation - caller cancellation, combined with the read deadline. + * @returns verified Host-local path and content identity, or null for nonlocal resources. + */ +export async function localDragResource(ctx: Context, address: string, cancellation?: AbortSignal): Promise { + const resource = parseFileAddress(address) + if (resource?.scope !== 'session') throw new Error('File drag requires a Session resource') + const sessions = ctx.get('sessions') + const persistence = ctx.get('sessionPersistence') + const fs = ctx.get('fs') + const policy = ctx.get('sandboxPolicy') + if (fs === undefined || sessions === undefined || policy === undefined) throw new Error('File access unavailable') + const sessionId = resource.sessionId as SessionId + const header = sessions.get(sessionId)?.header ?? (await persistence?.stat(sessionId))?.header + if (header === undefined) throw new Error('File drag Session unavailable') + const signal = cancellation === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([cancellation, AbortSignal.timeout(10_000)]) + signal.throwIfAborted() + const cwd = header.cwd ?? policy.workspaceRoot + const entry = await fs.lstat(resource.path, { cwd }, signal) + if (entry?.type !== 'file') return null + const target = await fs.resolve(resource.path, { cwd, signal }) + const path = fs.processPath(target) + const mapped = fs.processPathFromHostPath(path) + if (mapped === undefined) return null + const roundTrip = await fs.resolve(mapped, { signal }) + if (roundTrip.targetKey !== target.targetKey) return null + const info = await fs.stat(target, signal) + if (info?.type !== 'file' || info.size === undefined) return null + const hash = createHash('sha256') + let size = 0 + while (size < info.size) { + signal.throwIfAborted() + const data = await fs.readByteRange(target, { offset: size, length: Math.min(64 * 1024, info.size - size) }, signal) + if (data.length === 0) return null + hash.update(data) + size += data.length + } + // Even empty files require a read authorization check. + if (size === 0) await fs.readByteRange(target, { offset: 0, length: 1 }, signal) + const after = await fs.stat(target, signal) + signal.throwIfAborted() + if (after?.version !== info.version || after.size !== size) return null + return { path, size, sha256: hash.digest('hex') } +} diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..3109537b63 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-deepseek-account' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' +import { localDragResource } from './file-drag.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' import { installDesktopQuitInspection } from './quit-inspection.ts' @@ -41,6 +42,8 @@ async function main(): Promise { }), }) let stopping: Promise | undefined + const dragReads = new Map() + const dragTasks = new Set>() const control: { updateTasks?: ReturnType quitInspection?: ReturnType @@ -50,6 +53,8 @@ async function main(): Promise { process.send(message, (error) => { if (error === null) resolve(); else reject(error) }) }) const stop = (): Promise => stopping ??= (async () => { + for (const abort of dragReads.values()) abort.abort() + await Promise.allSettled(dragTasks) // Startup failure is reported by main; shutdown only owns a tree that booted. const running = await application.catch(() => undefined) await running?.shutdown.shutdown(0) @@ -62,6 +67,31 @@ async function main(): Promise { process.on('message', (message: unknown) => { if (typeof message !== 'object' || message === null || !('type' in message)) return if (message.type === 'shutdown') { void stop(); return } + if (message.type === 'file-drag-cancel') { + if ('requestId' in message && typeof message.requestId === 'number') dragReads.get(message.requestId)?.abort() + return + } + if (message.type === 'file-drag-resource') { + if (!('requestId' in message) || typeof message.requestId !== 'number' || !Number.isSafeInteger(message.requestId) + || !('address' in message) || typeof message.address !== 'string') return + const { requestId, address } = message + const abort = new AbortController() + dragReads.set(requestId, abort) + const task = (async () => { + try { + if (stopping !== undefined) throw new Error('Host stopping') + const { ctx } = await application + const resource = await localDragResource(ctx, address, abort.signal) + await send({ type: 'file-drag-resource', requestId, resource }) + } catch (error) { + if (!abort.signal.aborted) console.error(error) + await send({ type: 'file-drag-resource', requestId, resource: null, error: 'File drag resource unavailable' }) + } finally { dragReads.delete(requestId) } + })().catch((error: unknown) => { console.error(error) }) + dragTasks.add(task) + void task.finally(() => { dragTasks.delete(task) }) + return + } if (message.type === 'quit-inspection') { if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return const requestId = message.requestId @@ -104,7 +134,7 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'platform-session', session }) }) const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) - if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) + if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } /** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ diff --git a/apps/desktop-host/tests/file-drag.spec.ts b/apps/desktop-host/tests/file-drag.spec.ts new file mode 100644 index 0000000000..437a062e05 --- /dev/null +++ b/apps/desktop-host/tests/file-drag.spec.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import SessionStore from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { localDragResource } from '../src/file-drag.ts' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-host-drag-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) }) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(SessionStore) + ctx.provide('sandboxPolicy', { workspaceRoot: root } as never) + const id = SessionId('file-drag-test') + ctx.sessions.create(id, { meta: { cwd: root, origin: 'subagent' } }) + const path = join(root, '报告.pdf') + const bytes = Buffer.from('%PDF-1.4\n原始内容\n%%EOF') + await writeFile(path, bytes) + return { ctx, root, id, path, bytes, address: sessionFileAddress(id, '报告.pdf') } +} + +it('authorizes Session-relative file reads and returns their original digest privately to main', async () => { + const f = await fixture() + expect(await localDragResource(f.ctx, f.address)).toEqual({ + path: await realpath(f.path), size: f.bytes.length, sha256: createHash('sha256').update(f.bytes).digest('hex'), + }) +}) + +it('rejects unscoped references and missing Sessions', async () => { + const f = await fixture() + await expect(localDragResource(f.ctx, f.path)).rejects.toThrow('Session resource') + await expect(localDragResource(f.ctx, sessionFileAddress('missing', f.path))).rejects.toThrow('Session unavailable') + await expect(localDragResource(f.ctx, sessionFileAddress(f.id, f.root))).resolves.toBeNull() +}) + +it('does not authorize borrowing when the filesystem has no Host mapping', async () => { + const f = await fixture() + vi.spyOn(f.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect(await localDragResource(f.ctx, f.address)).toBeNull() +}) + +it('honors filesystem read denial without returning a path', async () => { + const f = await fixture() + vi.spyOn(f.ctx.fs, 'readByteRange').mockRejectedValue(new Error('read denied')) + await expect(localDragResource(f.ctx, f.address)).rejects.toThrow('read denied') +}) + +it.skipIf(process.platform === 'win32')('declines symlink borrowing so the authorized byte reader can copy it', async () => { + const f = await fixture() + await symlink(f.path, join(f.root, 'linked.pdf')) + expect(await localDragResource(f.ctx, sessionFileAddress(f.id, 'linked.pdf'))).toBeNull() +}) + +it('stops a chunked authorization read when the caller cancels', async () => { + const f = await fixture() + const pending = Promise.withResolvers() + const reading = vi.spyOn(f.ctx.fs, 'readByteRange').mockReturnValue(pending.promise) + const abort = new AbortController() + const preparing = localDragResource(f.ctx, f.address, abort.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(reading).toHaveBeenCalledOnce() }) + const signal = reading.mock.calls[0]![2] + abort.abort() + expect(signal?.aborted).toBe(true) + pending.resolve(f.bytes) + await rejected +}) + +it('authorizes empty files and detects content changes while reading', async () => { + const f = await fixture() + await writeFile(f.path, '') + expect(await localDragResource(f.ctx, f.address)).toMatchObject({ size: 0, sha256: createHash('sha256').digest('hex') }) + await writeFile(f.path, f.bytes) + vi.spyOn(f.ctx.fs, 'readByteRange').mockResolvedValueOnce(new Uint8Array()) + await expect(localDragResource(f.ctx, f.address)).resolves.toBeNull() +}) diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 02e741d4ce..33adbf68e7 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' @@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect('default' in desktopOffice).toBe(false) expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], + ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor], ['skills', SkillRegistry], ['office', desktopOffice], ]) ctx.loader.internal = { diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 31c64f179b..58eaa722db 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -30,6 +30,24 @@ }, { "path": "../../packages/credentials/deepseek-account" + }, + { + "path": "../../packages/fs/fs" + }, + { + "path": "../../packages/core/session" + }, + { + "path": "../../packages/session/session-persistence" + }, + { + "path": "../../packages/sandbox/sandbox-policy" + }, + { + "path": "../../packages/util/workspace-path" + }, + { + "path": "../../packages/util/brand" } ] } diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index da5502ce5d..cc3d21f3e8 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -1,7 +1,7 @@ # Release configuration only; local --unsigned builds ignore this file. # Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. @@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 # Optional independent proxy origins; empty values preserve inherited networking. # Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads. DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 4950c70b27..9d1a976d5d 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -1,6 +1,6 @@ # Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. @@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Cache-hit restore and verification workers (1-8). Hardware signing remains serial. DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9); lower values trade size for packaging time. +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index bda88b0fc6..01b1154394 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -21,8 +21,8 @@ en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: 24387a07ae4eb8e7 - zh: 0119352685cf273c + en: bb35a022c37955c9 + zh: ab2ef22d398d83a0 /deepseek-harness-desktop/key-technical-decisions: en: 3724c53da4f7647b zh: 76ae549d43c99bb4 @@ -33,8 +33,8 @@ en: 372297005b57a08b zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: - en: 22153a298a6662e1 - zh: 3600803c8c9d0274 + en: 160b500cdcd6a39a + zh: 6c267f1762ec3689 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 @@ -45,8 +45,8 @@ en: faf7676ba18bbf2f zh: f1d7b75ad348c548 /deepseek-harness-desktop/develop/startup-onboarding: - en: a51346b8d880ddba - zh: 7913214c1c66063c + en: 67dc7ef3e293236a + zh: 1b224db684a2d1d7 /deepseek-harness-desktop/develop/welcome-window-appearance: en: 617a89aeb28131b9 zh: 4d84bd5d86501c86 @@ -57,14 +57,14 @@ en: 9b90a2651a6d2b52 zh: 86d4d9c4358aad80 /deepseek-harness-desktop/updates: - en: 87db37529b51da62 - zh: 4fc0d752e437e7d6 + en: 67bf0a18faa7aa76 + zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d /deepseek-harness-desktop/updates/mandatory-update-policy: - en: 54596bef0706d305 - zh: 48737302dcf3c939 + en: 182a9c6705213e13 + zh: 681762f83490f4c6 /deepseek-harness-desktop/updates/local-updater-qualification: en: 68ec2f8bd5763ea6 zh: 23b932dfe8503602 @@ -72,8 +72,8 @@ en: 8264b135871e8978 zh: 0939aafb1b2961fc /deepseek-harness-desktop/known-limitations: - en: c63d4dc7a9104954 - zh: a4c676e5b46a6ac0 + en: b1eeadfefb338056 + zh: 28683fbdf1e98685 /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 45c94d62c7..1d5292c68b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon. -Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. +Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation. @@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The file-drag bridge accepts arbitrary file bytes or Session-scoped resource references from the owned product main frame. The Host authorizes resource reads through its composed filesystem, confirms a local mapping, and returns a private content digest; main checks content and the shared export filename before borrowing the source. Native startup checks the prepared file’s device, inode, size, modification time, and change time without rereading its bytes. Borrowed files are never deleted. Renamed, transformed, remote, or Blob content uses a private temporary copy. MIME data completes names without restricting file types; compatible suffixes such as `.jpeg` survive. Temporary preparations, partial writes, and retained exports share 64 MiB; unused files release on pointer leave, and OS-delivered copies expire after 60 seconds. Exhaustion reports the generic retry message until capacity is reclaimed. Caller cancellation and shutdown abort Host reads and main-process verification, interrupt icon waits, and release unused files. Shutdown waits for writes/releases and retries failed removals without waiting for the icon service. Update preparation attempts cleanup before stopping the Host; cleanup failures are logged and do not block installation or shutdown. Failed installation restores the bridge. Asynchronous system file icons avoid decoding original images in main. + Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. @@ -150,7 +152,7 @@ The product UI retains Web actions, including "Open In..." through the shared au Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. +Electron chooses typed shell copy in the eight [shipped Client languages](../../packages/client/locale/README.md) from its application locale and falls back to English. The macOS application bundle declares all eight languages in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. Windows NSIS installer resources live in one JSON file per language under `installer/locales/`; packaging generates NSIS strings and discovers the installer language list from these files. All shipped language resources must have matching keys, format placeholders, and command examples; `pnpm run check:i18n` validates them. The native installer test builds and exercises each configured language. Platform account pages and onboarding artwork use English for languages other than Chinese. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance. @@ -218,7 +220,7 @@ Repeated launches and `dsh://open` keep the workspace hidden until the startup c Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice. -The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. +The welcome window reads the shared `locale.preference` before it appears. An explicit supported language choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. The browser-login waiting page offers a copy-link action for the current pending authorization, a loading indicator, and cancellation. Clipboard failures leave the copy action available for retry. Copy feedback resets after two seconds; the copied state disables the link until it resets. Welcome text uses Montserrat Light with system fallbacks; large action buttons keep the system font, while text buttons use Montserrat Light. English introductory copy is 24px throughout. Chinese introductory copy is 24px with a 26px product name. Login action buttons are 240px wide with 14px labels. Authorization status headings use 20px Montserrat Regular text. The API-key page uses a 20px heading and a 14px back action, with 84px between the secondary button’s bottom edge and the window bottom. @@ -271,9 +273,11 @@ On Windows, the downloaded-update confirmation explains that the application clo On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window. -Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. +Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached. -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. +Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline. + +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -330,10 +334,12 @@ When permitted, login is offered during user-initiated checks and the packaged a Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. +Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification. + Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required. ### Local updater qualification @@ -375,7 +381,7 @@ When an expired account returns to Welcome, the main process retains a one-time An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. -Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. +Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 0504c7004c..c2078e9d30 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会 Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。 -所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 +所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。 @@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 +文件拖放桥接接受所属主应用主框架传入的任意文件字节或 Session 资源地址。Host 通过组合的文件系统授权读取、确认本地映射并返回私有内容摘要;主进程检查内容与统一解析的导出名后借用源文件。原生起拖核对准备时的设备、inode、大小、修改时间和变更时间,不再读取整份字节。借用文件不会被删除。改名、转换、远程或 Blob 内容使用私有临时副本。MIME 数据补全名称,不限制文件类型;`.jpeg` 等兼容后缀保持不变。临时准备、部分写入及保留的导出共享 64 MiB;指针移开释放未使用文件,已交给系统的副本在 60 秒后过期。空间耗尽时显示通用重试提示,容量回收后可重试。调用方取消及退出会中止 Host 读取和主进程验证、打断图标等待并释放未使用文件。退出等待写入和释放,重试失败的删除,但不等待图标服务。更新准备在停止 Host 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。 + 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 @@ -150,7 +152,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的八种[内置 Client 语言](../../packages/client/locale/README.zh.md)的 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持全部八种语言,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。Windows NSIS 安装器在 `installer/locales/` 下按语言保存独立 JSON 资源;打包从这些文件生成 NSIS 文案和安装器语言清单。全部内置语言资源的键、格式占位符和命令示例必须一致,`pnpm run check:i18n` 负责检查。原生安装器测试逐个构建并验证配置中的语言。账户平台页面和引导插图对中文以外的语言使用英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。 @@ -218,7 +220,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。 -欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 +欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的受支持语言优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。 @@ -271,9 +273,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。 -原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 +普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 +普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。 + +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -330,10 +334,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 +已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。 + 后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。 ### 本地 updater 验证 @@ -375,7 +381,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 -内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 +内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。 diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example index 18360eadef..a5a5477fbe 100644 --- a/apps/desktop/config/macos-packaging/production.env.example +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -1,8 +1,9 @@ # Copy to production.env and configure the production service before selecting this preset. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= # Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example index aa40a119ce..4c5de070a5 100644 --- a/apps/desktop/config/macos-packaging/test.env.example +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -1,5 +1,5 @@ # Copy to test.env; paths in secret configuration resolve beside macos-signing.env. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' @@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN= # Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing. DOWNLOAD_TEST_RELEASE_ID= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= # Temporarily changes the active network service's system HTTP/HTTPS proxies. DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example index 2e05825590..dbbcfb73d5 100644 --- a/apps/desktop/config/windows-packaging/production.env.example +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -1,7 +1,9 @@ # Copy to production.env in the same machine configuration directory and fill in the values. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 718481f13a..5a4e2ccf90 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -1,6 +1,6 @@ # Copy to test.env in the same machine configuration directory and fill in the values. # Public test configuration. No PIN or upload credentials belong here. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= # Replace login.example.com with the real login origin before packaging. @@ -11,3 +11,5 @@ DOWNLOAD_TEST_RELEASE_ID= # Optional absolute private cache directory. Omit to retain the account default cache. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index d6a1a63bfa..2973a6eacc 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -22,7 +22,10 @@ export interface DesktopElectronBuilderConfig { ...{ readonly from: string, readonly to: 'tray.ico' }[], ] readonly mac: { - readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } + readonly extendInfo: { + readonly CFBundleLocalizations: readonly string[] + readonly NSMicrophoneUsageDescription: string + } readonly entitlements: string readonly entitlementsInherit: string readonly identity: string | undefined @@ -50,7 +53,7 @@ export interface DesktopElectronBuilderConfig { readonly perMachine: false readonly allowElevation: false readonly allowToChangeInstallationDirectory: false - readonly installerLanguages: readonly ['en_US', 'zh_CN'] + readonly installerLanguages: readonly string[] } readonly beforeBuild: () => Promise readonly beforePack: (context: BeforePackContext) => Promise diff --git a/apps/desktop/installer/locales/de_DE.json b/apps/desktop/installer/locales/de_DE.json new file mode 100644 index 0000000000..faf725b88e --- /dev/null +++ b/apps/desktop/installer/locales/de_DE.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "GERMAN", + "strings": { + "INSTALLER_INSTALL": "Jetzt installieren", + "INSTALLER_FINISH": "Fertigstellen", + "INSTALLER_NATIVE_PROGRESS": "Installation wird vorbereitet… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Dateien extrahieren… %d%%", + "INSTALLER_PROGRESS_COPY": "Dateien werden installiert… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Abschluss der Installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Bereinigen temporärer Dateien… %d%%", + "INSTALLER_CHOOSE_PATH": "Standort wählen", + "INSTALLER_BROWSE": "Durchsuchen", + "INSTALLER_LAUNCH": "Jetzt starten", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness konnte nicht gestartet werden. Versuchen Sie es noch einmal oder deaktivieren Sie „Jetzt starten“ und schließen Sie das Installationsprogramm.", + "INSTALLER_UI_ERROR": "Das Installationsprogramm konnte sein Fenster nicht initialisieren. Schließen Sie es und versuchen Sie es erneut.", + "INSTALLER_THEME_ERROR": "Verwenden Sie /THEME=auto, /THEME=light oder /THEME=dark.", + "INSTALLER_FAILED": "Die Installation wurde nicht abgeschlossen. Schließen Sie dieses Installationsprogramm und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, laden Sie das Installationsprogramm erneut herunter.", + "INSTALLER_RUNNING": "DeepSeek Harness läuft. Beenden Sie es über die Taskleiste und versuchen Sie es erneut.", + "INSTALLER_DIRECTORY_FAILED": "Das Installationsverzeichnis konnte nicht ersetzt werden.", + "INSTALLER_DIRECTORY_HINT": "Überprüfen Sie den Zugriff auf das Installationsverzeichnis und schließen Sie Programme mithilfe seiner Dateien. Führen Sie dann das Installationsprogramm erneut aus. Kopieren Sie die Fehlerdetails, wenn das Problem weiterhin besteht.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "Die vorherige Installation konnte nicht wiederhergestellt werden. Behalten Sie das in den Fehlerdetails angezeigte Sicherungsverzeichnis bei und wenden Sie sich vor der erneuten Installation an den Support.", + "INSTALLER_PATH_INVALID": "Wählen Sie einen vollständigen lokalen Ordnerpfad. Laufwerksstämme, Systemordner, Links und Sonderzeichen werden nicht unterstützt.", + "INSTALLER_PATH_OWNERSHIP": "Wählen Sie einen leeren Ordner oder den registrierten DeepSeek Harness-Installationsordner.", + "INSTALLER_PATH_WRITABLE": "Dieser Ort ist nicht beschreibbar. Wählen Sie einen Ordner, der für den aktuellen Benutzer verfügbar ist.", + "INSTALLER_DISK_SPACE": "Es ist nicht genügend freier Speicherplatz vorhanden. Wählen Sie einen anderen Standort.", + "INSTALLER_PER_USER": "Dieses Installationsprogramm unterstützt nur den aktuellen Benutzer. Deinstallieren Sie zunächst die vorhandene All-User-Installation.", + "INSTALLER_EXTRACT_FAILED": "Die Anwendungsdateien konnten nicht extrahiert werden.", + "INSTALLER_EXTRACT_HINT": "Möglicherweise hat die Sicherheitssoftware eine Datei blockiert oder die Festplatte ist voll. Schließen Sie dieses Installationsprogramm und versuchen Sie es erneut. Wenn es weiterhin fehlschlägt, senden Sie uns die Fehlerdetails unten.", + "INSTALLER_EXTRACT_COPY": "Fehlerdetails kopieren", + "INSTALLER_EXTRACT_EXPAND": "Details anzeigen", + "INSTALLER_EXTRACT_COLLAPSE": "Details ausblenden", + "INSTALLER_EXTRACT_SAVED": "Der vollständige Bericht wurde unter %s gespeichert", + "INSTALLER_EXTRACT_UNSAVED": "Der vollständige Bericht konnte nicht gespeichert werden. Verwenden Sie „Fehlerdetails kopieren“, um sie beizubehalten.", + "INSTALLER_EXTRACT_COPIED": "Fehlerdetails wurden in die Zwischenablage kopiert." + }, + "uninstaller": { + "welcome": "Willkommen", + "finished": "entfernt worden" + } +} diff --git a/apps/desktop/installer/locales/en_US.json b/apps/desktop/installer/locales/en_US.json new file mode 100644 index 0000000000..f522ad9b6d --- /dev/null +++ b/apps/desktop/installer/locales/en_US.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "ENGLISH", + "strings": { + "INSTALLER_INSTALL": "Install now", + "INSTALLER_FINISH": "Finish", + "INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extracting files… %d%%", + "INSTALLER_PROGRESS_COPY": "Installing files… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Finishing installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Cleaning temporary files… %d%%", + "INSTALLER_CHOOSE_PATH": "Choose location", + "INSTALLER_BROWSE": "Browse", + "INSTALLER_LAUNCH": "Launch now", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer.", + "INSTALLER_UI_ERROR": "The installer could not initialize its window. Close it and try again.", + "INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light or /THEME=dark.", + "INSTALLER_FAILED": "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again.", + "INSTALLER_RUNNING": "DeepSeek Harness is running. Quit it from the system tray and try again.", + "INSTALLER_DIRECTORY_FAILED": "The installation directory could not be replaced.", + "INSTALLER_DIRECTORY_HINT": "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again.", + "INSTALLER_PATH_INVALID": "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported.", + "INSTALLER_PATH_OWNERSHIP": "Choose an empty folder or the registered DeepSeek Harness installation folder.", + "INSTALLER_PATH_WRITABLE": "This location is not writable. Choose a folder available to the current user.", + "INSTALLER_DISK_SPACE": "There is not enough free disk space. Choose another location.", + "INSTALLER_PER_USER": "This installer supports the current user only. Uninstall the existing all-users installation first.", + "INSTALLER_EXTRACT_FAILED": "The application files could not be extracted.", + "INSTALLER_EXTRACT_HINT": "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below.", + "INSTALLER_EXTRACT_COPY": "Copy error details", + "INSTALLER_EXTRACT_EXPAND": "Show details", + "INSTALLER_EXTRACT_COLLAPSE": "Hide details", + "INSTALLER_EXTRACT_SAVED": "The full report was saved to %s", + "INSTALLER_EXTRACT_UNSAVED": "The full report could not be saved. Use Copy error details to keep it.", + "INSTALLER_EXTRACT_COPIED": "Error details were copied to the clipboard." + }, + "uninstaller": { + "welcome": "Welcome", + "finished": "has been uninstalled" + } +} diff --git a/apps/desktop/installer/locales/es_ES.json b/apps/desktop/installer/locales/es_ES.json new file mode 100644 index 0000000000..d4fbfd972f --- /dev/null +++ b/apps/desktop/installer/locales/es_ES.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "SPANISHINTERNATIONAL", + "strings": { + "INSTALLER_INSTALL": "Instalar ahora", + "INSTALLER_FINISH": "Finalizar", + "INSTALLER_NATIVE_PROGRESS": "Preparando la instalación… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extrayendo archivos… %d%%", + "INSTALLER_PROGRESS_COPY": "Instalando archivos… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Terminando la instalación… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Limpieza de archivos temporales… %d%%", + "INSTALLER_CHOOSE_PATH": "Elige ubicación", + "INSTALLER_BROWSE": "Examinar", + "INSTALLER_LAUNCH": "Lanzar ahora", + "INSTALLER_LAUNCH_FAILED": "No se pudo iniciar DeepSeek Harness. Vuelva a intentarlo o desmarque Iniciar ahora y cierre el instalador.", + "INSTALLER_UI_ERROR": "El instalador no pudo inicializar su ventana. Ciérralo y vuelve a intentarlo.", + "INSTALLER_THEME_ERROR": "Utilice /THEME=auto, /THEME=light o /THEME=dark.", + "INSTALLER_FAILED": "La instalación no terminó. Cierre este instalador y vuelva a intentarlo. Si el problema persiste, descargue el instalador nuevamente.", + "INSTALLER_RUNNING": "DeepSeek Harness se está ejecutando. Salga de la bandeja del sistema y vuelva a intentarlo.", + "INSTALLER_DIRECTORY_FAILED": "No se pudo reemplazar el directorio de instalación.", + "INSTALLER_DIRECTORY_HINT": "Verifique el acceso al directorio de instalación y cierre los programas usando sus archivos, luego ejecute el instalador nuevamente. Copie los detalles del error si el problema persiste.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "No se pudo restaurar la instalación anterior. Mantenga el directorio de respaldo que se muestra en los detalles del error y comuníquese con el soporte antes de volver a instalar.", + "INSTALLER_PATH_INVALID": "Elija una ruta de carpeta local completa. No se admiten raíces de unidades, carpetas del sistema, enlaces ni caracteres especiales.", + "INSTALLER_PATH_OWNERSHIP": "Elija una carpeta vacía o la carpeta de instalación registrada de DeepSeek Harness.", + "INSTALLER_PATH_WRITABLE": "Esta ubicación no se puede escribir. Elija una carpeta disponible para el usuario actual.", + "INSTALLER_DISK_SPACE": "No hay suficiente espacio libre en disco. Elija otra ubicación.", + "INSTALLER_PER_USER": "Este instalador solo admite el usuario actual. Primero desinstale la instalación existente para todos los usuarios.", + "INSTALLER_EXTRACT_FAILED": "No se pudieron extraer los archivos de la aplicación.", + "INSTALLER_EXTRACT_HINT": "Es posible que el software de seguridad haya bloqueado un archivo o que el disco esté lleno. Cierra este instalador y vuelve a intentarlo. Si sigue fallando, envíenos los detalles del error a continuación.", + "INSTALLER_EXTRACT_COPY": "Copiar detalles del error", + "INSTALLER_EXTRACT_EXPAND": "Mostrar detalles", + "INSTALLER_EXTRACT_COLLAPSE": "Ocultar detalles", + "INSTALLER_EXTRACT_SAVED": "El informe completo se guardó en %s.", + "INSTALLER_EXTRACT_UNSAVED": "No se pudo guardar el informe completo. Utilice Copiar detalles del error para conservarlo.", + "INSTALLER_EXTRACT_COPIED": "Los detalles del error se copiaron al portapapeles." + }, + "uninstaller": { + "welcome": "Bienvenido", + "finished": "ha sido desinstalado" + } +} diff --git a/apps/desktop/installer/locales/fr_FR.json b/apps/desktop/installer/locales/fr_FR.json new file mode 100644 index 0000000000..326ead62a8 --- /dev/null +++ b/apps/desktop/installer/locales/fr_FR.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "FRENCH", + "strings": { + "INSTALLER_INSTALL": "Installer maintenant", + "INSTALLER_FINISH": "Terminer", + "INSTALLER_NATIVE_PROGRESS": "Préparation de l'installation… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extraction de fichiers… %d%%", + "INSTALLER_PROGRESS_COPY": "Installation des fichiers… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Finition de l'installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Nettoyage des fichiers temporaires… %d%%", + "INSTALLER_CHOOSE_PATH": "Choisissez un emplacement", + "INSTALLER_BROWSE": "Parcourir", + "INSTALLER_LAUNCH": "Lancez-vous maintenant", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness n'a pas pu être lancé. Réessayez ou désactivez Lancer maintenant et fermez le programme d'installation.", + "INSTALLER_UI_ERROR": "Le programme d'installation n'a pas pu initialiser sa fenêtre. Fermez-le et réessayez.", + "INSTALLER_THEME_ERROR": "Utilisez /THEME=auto, /THEME=light ou /THEME=dark.", + "INSTALLER_FAILED": "L'installation n'est pas terminée. Fermez ce programme d'installation et réessayez. Si le problème persiste, téléchargez à nouveau le programme d'installation.", + "INSTALLER_RUNNING": "DeepSeek Harness est en cours d'exécution. Quittez-le de la barre d'état système et réessayez.", + "INSTALLER_DIRECTORY_FAILED": "Le répertoire d'installation n'a pas pu être remplacé.", + "INSTALLER_DIRECTORY_HINT": "Vérifiez l'accès au répertoire d'installation et fermez les programmes à l'aide de ses fichiers, puis réexécutez le programme d'installation. Copiez les détails de l'erreur si le problème persiste.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "L'installation précédente n'a pas pu être restaurée. Conservez le répertoire de sauvegarde indiqué dans les détails de l'erreur et contactez l'assistance avant de procéder à une nouvelle installation.", + "INSTALLER_PATH_INVALID": "Choisissez un chemin de dossier local complet. Les racines du lecteur, les dossiers système, les liens et les caractères spéciaux ne sont pas pris en charge.", + "INSTALLER_PATH_OWNERSHIP": "Choisissez un dossier vide ou le dossier d'installation DeepSeek Harness enregistré.", + "INSTALLER_PATH_WRITABLE": "Cet emplacement n'est pas accessible en écriture. Choisissez un dossier disponible pour l'utilisateur actuel.", + "INSTALLER_DISK_SPACE": "Il n'y a pas assez d'espace disque libre. Choisissez un autre emplacement.", + "INSTALLER_PER_USER": "Ce programme d'installation prend en charge uniquement l'utilisateur actuel. Désinstallez d’abord l’installation existante pour tous les utilisateurs.", + "INSTALLER_EXTRACT_FAILED": "Les fichiers de l'application n'ont pas pu être extraits.", + "INSTALLER_EXTRACT_HINT": "Un logiciel de sécurité a peut-être bloqué un fichier ou le disque est peut-être plein. Fermez ce programme d'installation et réessayez. Si l'échec persiste, envoyez-nous les détails de l'erreur ci-dessous.", + "INSTALLER_EXTRACT_COPY": "Copier les détails de l'erreur", + "INSTALLER_EXTRACT_EXPAND": "Afficher les détails", + "INSTALLER_EXTRACT_COLLAPSE": "Masquer les détails", + "INSTALLER_EXTRACT_SAVED": "Le rapport complet a été enregistré dans %s", + "INSTALLER_EXTRACT_UNSAVED": "Le rapport complet n'a pas pu être enregistré. Utilisez Copier les détails de l'erreur pour le conserver.", + "INSTALLER_EXTRACT_COPIED": "Les détails de l'erreur ont été copiés dans le presse-papiers." + }, + "uninstaller": { + "welcome": "Bienvenue", + "finished": "a été supprimé" + } +} diff --git a/apps/desktop/installer/locales/ja_JP.json b/apps/desktop/installer/locales/ja_JP.json new file mode 100644 index 0000000000..ee614789c5 --- /dev/null +++ b/apps/desktop/installer/locales/ja_JP.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "JAPANESE", + "strings": { + "INSTALLER_INSTALL": "今すぐインストール", + "INSTALLER_FINISH": "完了", + "INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "ファイルを展開しています… %d%%", + "INSTALLER_PROGRESS_COPY": "ファイルをインストールしています… %d%%", + "INSTALLER_PROGRESS_REGISTER": "インストールを完了しています… %d%%", + "INSTALLER_PROGRESS_CLEAN": "一時ファイルを削除しています… %d%%", + "INSTALLER_CHOOSE_PATH": "インストール先を選択", + "INSTALLER_BROWSE": "参照", + "INSTALLER_LAUNCH": "今すぐ起動", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness を起動できませんでした。再試行するか、「今すぐ起動」のチェックを外してインストーラーを閉じてください。", + "INSTALLER_UI_ERROR": "インストーラーのウィンドウを初期化できませんでした。閉じてから再試行してください。", + "INSTALLER_THEME_ERROR": "/THEME=auto、/THEME=light、/THEME=dark のいずれかを指定してください。", + "INSTALLER_FAILED": "インストールが完了しませんでした。インストーラーを閉じて再試行してください。問題が解決しない場合は、インストーラーを再度ダウンロードしてください。", + "INSTALLER_RUNNING": "DeepSeek Harness が実行中です。システムトレイから終了して再試行してください。", + "INSTALLER_DIRECTORY_FAILED": "インストール先のディレクトリを置き換えられませんでした。", + "INSTALLER_DIRECTORY_HINT": "インストール先へのアクセス権限を確認し、そのファイルを使用しているプログラムを閉じてから、インストーラーを再実行してください。問題が続く場合はエラーの詳細をコピーしてください。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "以前のインストールを復元できませんでした。エラーの詳細に表示されたバックアップディレクトリを保持し、再インストール前にサポートへお問い合わせください。", + "INSTALLER_PATH_INVALID": "ローカルフォルダーの完全なパスを選択してください。ドライブのルート、システムフォルダー、リンク、特殊文字を含むパスは使用できません。", + "INSTALLER_PATH_OWNERSHIP": "空のフォルダー、または登録済みの DeepSeek Harness インストール先を選択してください。", + "INSTALLER_PATH_WRITABLE": "この場所には書き込めません。現在のユーザーが書き込めるフォルダーを選択してください。インストーラーは管理者権限を要求しません。", + "INSTALLER_DISK_SPACE": "ディスクの空き容量が不足しています。別の場所を選択してください。", + "INSTALLER_PER_USER": "このインストーラーは現在のユーザーのみを対象としています。先にすべてのユーザー向けの既存のインストールを削除してください。", + "INSTALLER_EXTRACT_FAILED": "アプリケーションのファイルを展開できませんでした。", + "INSTALLER_EXTRACT_HINT": "セキュリティソフトがファイルをブロックしたか、ディスクの空き容量が不足している可能性があります。インストーラーを閉じて再試行してください。問題が続く場合は、以下のエラーの詳細をお送りください。", + "INSTALLER_EXTRACT_COPY": "エラーの詳細をコピー", + "INSTALLER_EXTRACT_EXPAND": "詳細を表示", + "INSTALLER_EXTRACT_COLLAPSE": "詳細を非表示", + "INSTALLER_EXTRACT_SAVED": "完全なレポートを %s に保存しました", + "INSTALLER_EXTRACT_UNSAVED": "完全なレポートを保存できませんでした。「エラーの詳細をコピー」で保存してください。", + "INSTALLER_EXTRACT_COPIED": "エラーの詳細をクリップボードにコピーしました。" + }, + "uninstaller": { + "welcome": "アンインストール ウィザードへようこそ", + "finished": "アンインストールされました" + } +} diff --git a/apps/desktop/installer/locales/pt_BR.json b/apps/desktop/installer/locales/pt_BR.json new file mode 100644 index 0000000000..dda249e46f --- /dev/null +++ b/apps/desktop/installer/locales/pt_BR.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "PORTUGUESEBR", + "strings": { + "INSTALLER_INSTALL": "Instale agora", + "INSTALLER_FINISH": "Concluir", + "INSTALLER_NATIVE_PROGRESS": "Preparando a instalação… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extraindo arquivos… %d%%", + "INSTALLER_PROGRESS_COPY": "Instalando arquivos… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Concluindo a instalação… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Limpando arquivos temporários… %d%%", + "INSTALLER_CHOOSE_PATH": "Escolha o local", + "INSTALLER_BROWSE": "Procurar", + "INSTALLER_LAUNCH": "Lançar agora", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness não pôde ser iniciado. Tente novamente ou desmarque Iniciar agora e feche o instalador.", + "INSTALLER_UI_ERROR": "O instalador não conseguiu inicializar sua janela. Feche-o e tente novamente.", + "INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light ou /THEME=dark.", + "INSTALLER_FAILED": "A instalação não foi concluída. Feche este instalador e tente novamente. Se o problema persistir, baixe o instalador novamente.", + "INSTALLER_RUNNING": "DeepSeek Harness está em execução. Saia da bandeja do sistema e tente novamente.", + "INSTALLER_DIRECTORY_FAILED": "O diretório de instalação não pôde ser substituído.", + "INSTALLER_DIRECTORY_HINT": "Verifique o acesso ao diretório de instalação e feche os programas usando seus arquivos e execute o instalador novamente. Copie os detalhes do erro se o problema persistir.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "A instalação anterior não pôde ser restaurada. Mantenha o diretório de backup mostrado nos detalhes do erro e entre em contato com o suporte antes de instalar novamente.", + "INSTALLER_PATH_INVALID": "Escolha um caminho completo para a pasta local. Raízes de unidade, pastas de sistema, links e caracteres especiais não são suportados.", + "INSTALLER_PATH_OWNERSHIP": "Escolha uma pasta vazia ou a pasta de instalação DeepSeek Harness registrada.", + "INSTALLER_PATH_WRITABLE": "Este local não é gravável. Escolha uma pasta disponível para o usuário atual.", + "INSTALLER_DISK_SPACE": "Não há espaço livre suficiente em disco. Escolha outro local.", + "INSTALLER_PER_USER": "Este instalador oferece suporte apenas ao usuário atual. Desinstale primeiro a instalação existente para todos os usuários.", + "INSTALLER_EXTRACT_FAILED": "Os arquivos do aplicativo não puderam ser extraídos.", + "INSTALLER_EXTRACT_HINT": "O software de segurança pode ter bloqueado um arquivo ou o disco pode estar cheio. Feche este instalador e tente novamente. Se continuar falhando, envie-nos os detalhes do erro abaixo.", + "INSTALLER_EXTRACT_COPY": "Copiar detalhes do erro", + "INSTALLER_EXTRACT_EXPAND": "Mostrar detalhes", + "INSTALLER_EXTRACT_COLLAPSE": "Ocultar detalhes", + "INSTALLER_EXTRACT_SAVED": "O relatório completo foi salvo em %s", + "INSTALLER_EXTRACT_UNSAVED": "Não foi possível salvar o relatório completo. Use Copiar detalhes do erro para mantê-lo.", + "INSTALLER_EXTRACT_COPIED": "Os detalhes do erro foram copiados para a área de transferência." + }, + "uninstaller": { + "welcome": "Bem-vindo", + "finished": "foi desinstalado" + } +} diff --git a/apps/desktop/installer/locales/ru_RU.json b/apps/desktop/installer/locales/ru_RU.json new file mode 100644 index 0000000000..e5fec3f8d1 --- /dev/null +++ b/apps/desktop/installer/locales/ru_RU.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "RUSSIAN", + "strings": { + "INSTALLER_INSTALL": "Установить сейчас", + "INSTALLER_FINISH": "Готово", + "INSTALLER_NATIVE_PROGRESS": "Подготовка установки… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Извлечение файлов… %d%%", + "INSTALLER_PROGRESS_COPY": "Установка файлов… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Завершение установки… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Очистка временных файлов… %d%%", + "INSTALLER_CHOOSE_PATH": "Выберите местоположение", + "INSTALLER_BROWSE": "Обзор", + "INSTALLER_LAUNCH": "Запустить сейчас", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness не удалось запустить. Повторите попытку или снимите флажок «Запустить сейчас» и закройте программу установки.", + "INSTALLER_UI_ERROR": "Установщику не удалось инициализировать свое окно. Закройте его и попробуйте еще раз.", + "INSTALLER_THEME_ERROR": "Используйте /THEME=auto, /THEME=light или /THEME=dark.", + "INSTALLER_FAILED": "Установка не завершена. Закройте этот установщик и повторите попытку. Если проблема не устранена, загрузите установщик еще раз.", + "INSTALLER_RUNNING": "DeepSeek Harness работает. Закройте его из системного трея и повторите попытку.", + "INSTALLER_DIRECTORY_FAILED": "Не удалось заменить каталог установки.", + "INSTALLER_DIRECTORY_HINT": "Проверьте доступ к каталогу установки и закройте программы, использующие его файлы, затем снова запустите установщик. Скопируйте сведения об ошибке, если проблема не устранена.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "Предыдущую установку восстановить не удалось. Сохраните каталог резервной копии, указанный в сведениях об ошибке, и обратитесь в службу поддержки перед повторной установкой.", + "INSTALLER_PATH_INVALID": "Выберите полный путь к локальной папке. Корни дисков, системные папки, ссылки и специальные символы не поддерживаются.", + "INSTALLER_PATH_OWNERSHIP": "Выберите пустую папку или зарегистрированную папку установки DeepSeek Harness.", + "INSTALLER_PATH_WRITABLE": "Это местоположение недоступно для записи. Выберите папку, доступную текущему пользователю.", + "INSTALLER_DISK_SPACE": "Недостаточно свободного места на диске. Выберите другое место.", + "INSTALLER_PER_USER": "Этот установщик поддерживает только текущего пользователя. Сначала удалите существующую установку для всех пользователей.", + "INSTALLER_EXTRACT_FAILED": "Файлы приложения не удалось извлечь.", + "INSTALLER_EXTRACT_HINT": "Возможно, программное обеспечение безопасности заблокировало файл или диск переполнен. Закройте этот установщик и повторите попытку. Если ошибка продолжает возникать, отправьте нам сведения об ошибке ниже.", + "INSTALLER_EXTRACT_COPY": "Скопировать сведения об ошибке", + "INSTALLER_EXTRACT_EXPAND": "Показать детали", + "INSTALLER_EXTRACT_COLLAPSE": "Скрыть детали", + "INSTALLER_EXTRACT_SAVED": "Полный отчет сохранен в %s.", + "INSTALLER_EXTRACT_UNSAVED": "Полный отчет не удалось сохранить. Используйте Скопировать сведения об ошибке, чтобы сохранить их.", + "INSTALLER_EXTRACT_COPIED": "Подробности об ошибке скопированы в буфер обмена." + }, + "uninstaller": { + "welcome": "Вас приветствует мастер удаления", + "finished": "удалена" + } +} diff --git a/apps/desktop/installer/locales/zh_CN.json b/apps/desktop/installer/locales/zh_CN.json new file mode 100644 index 0000000000..e3f40f13be --- /dev/null +++ b/apps/desktop/installer/locales/zh_CN.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "SIMPCHINESE", + "strings": { + "INSTALLER_INSTALL": "立即安装", + "INSTALLER_FINISH": "完成", + "INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "正在解压文件… %d%%", + "INSTALLER_PROGRESS_COPY": "正在安装文件… %d%%", + "INSTALLER_PROGRESS_REGISTER": "正在完成安装… %d%%", + "INSTALLER_PROGRESS_CLEAN": "正在清理临时文件… %d%%", + "INSTALLER_CHOOSE_PATH": "选择安装位置", + "INSTALLER_BROWSE": "选择", + "INSTALLER_LAUNCH": "立即启动", + "INSTALLER_LAUNCH_FAILED": "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。", + "INSTALLER_UI_ERROR": "无法初始化安装窗口,请关闭后重试。", + "INSTALLER_THEME_ERROR": "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。", + "INSTALLER_FAILED": "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。", + "INSTALLER_RUNNING": "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。", + "INSTALLER_DIRECTORY_FAILED": "无法替换安装目录。", + "INSTALLER_DIRECTORY_HINT": "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。", + "INSTALLER_PATH_INVALID": "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。", + "INSTALLER_PATH_OWNERSHIP": "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。", + "INSTALLER_PATH_WRITABLE": "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。", + "INSTALLER_DISK_SPACE": "此磁盘的可用空间不足,请选择其他位置。", + "INSTALLER_PER_USER": "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。", + "INSTALLER_EXTRACT_FAILED": "无法解压应用程序文件。", + "INSTALLER_EXTRACT_HINT": "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。", + "INSTALLER_EXTRACT_COPY": "复制错误信息", + "INSTALLER_EXTRACT_EXPAND": "显示详情", + "INSTALLER_EXTRACT_COLLAPSE": "隐藏详情", + "INSTALLER_EXTRACT_SAVED": "完整错误报告已保存到 %s", + "INSTALLER_EXTRACT_UNSAVED": "无法保存完整错误报告,请使用“复制错误信息”保留。", + "INSTALLER_EXTRACT_COPIED": "错误信息已复制到剪贴板。" + }, + "uninstaller": { + "welcome": "欢迎", + "finished": "已从你" + } +} diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh deleted file mode 100644 index a1ac41b418..0000000000 --- a/apps/desktop/installer/strings.nsh +++ /dev/null @@ -1,63 +0,0 @@ -; NSIS owns installer localization independently of the Electron application locale. -LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now" -LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装" -LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish" -LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%" -LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location" -LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置" -LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse" -LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择" -LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now" -LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动" -LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer." -LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。" -LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again." -LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。" -LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark." -LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。" -LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again." -LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" -LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." -LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" -LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced." -LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。" -LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists." -LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。" -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again." -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。" -LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." -LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" -LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." -LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。" -LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user." -LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。" -LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location." -LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。" -LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first." -LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。" -LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted." -LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。" -LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below." -LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。" -LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details" -LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情" -LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s" -LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s" -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it." -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。" -LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard." -LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64a6bff8fc..f8c411c367 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "private": true, "license": "MIT", "author": { @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/node-addon-system": "workspace:~", "@electron/get": "^5.1.0", "@electron/notarize": "2.5.0", @@ -82,6 +83,7 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*", - "@deepseek-ai/dsh-client-product-analytics": "workspace:*" + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" } } diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 96c2ee0cf0..7a9ff55492 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 17c6bcdefa05a8f8 - zh: 4ee18f832276144d + en: 0b35c7471c09b27a + zh: cc4a382b99fc3210 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca @@ -24,20 +24,20 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: e1b101593f6f755e - zh: 121f225de399de1e + en: 364223725374d9ac + zh: c4d4cc58d7cbafbd /desktop-packaging/macos-self-hosted-packaging: - en: a3af9523fdc60036 - zh: 5e18d4740e9b7f57 + en: bb1dcdfef3f3a113 + zh: 9c151800a61e2505 /desktop-packaging/unsigned-windows-test-installer: - en: 6843a0fdc03ead58 - zh: 957f92fc8126e23e + en: f429bc0c280e2dfa + zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: f4a2b14da34ba883 - zh: 6292f3a7b4038213 + en: 29fa6bd8919f3ec0 + zh: 9e31e037fa3d9378 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,14 +45,14 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: bfd89e1d139c5729 - zh: 5d6cced5462ba13e + en: 83e1075417039410 + zh: 3088a7ae0a2cc3c5 /desktop-packaging/desktop-runtime-preparation: - en: a7aa3066e649ffe8 - zh: 4334637910058c09 + en: ab73b67d899e611d + zh: d77b0525c6b81e91 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 /desktop-packaging/dev-note: - en: b75c8458928b72b9 - zh: 003ba4e17b4b5269 + en: 691194744993dfaa + zh: e5798faf99546ff8 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index a77d260591..14c1eac90b 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation). + ## Release versions @@ -113,18 +115,26 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. + +For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. + +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. + +Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. + ## macOS self-hosted packaging -The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. +The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). -The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. +ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: @@ -146,7 +156,7 @@ On Windows x64, use the complete unsigned packaging command for local installati pnpm run package:desktop:win:x64:unsigned ``` -The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. +The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell 7 (`pwsh`), tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. @@ -179,11 +189,12 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. -Mac packaging reads three tuning fields from `.env.macos`: +Mac packaging reads these tuning fields from `.env.macos`: | Setting | Default | Scope | |---|---|---| | `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | +| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. | | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | @@ -254,18 +265,26 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. + NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. ## Desktop runtime preparation +Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together. + +`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs. + Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. + Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. @@ -280,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review ## Dev Note -None. +XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 54e9d740f8..0f059c74a4 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。 + ## 发布版本 @@ -113,18 +115,26 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 + +测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 + +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 + +安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 + ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 +`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 -工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 +无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: @@ -146,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" pnpm run package:desktop:win:x64:unsigned ``` -该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 +该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 @@ -179,11 +189,12 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop- App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 -Mac 打包从 `.env.macos` 读取三个调优字段: +Mac 打包从 `.env.macos` 读取以下调优字段: | 设置 | 默认值 | 作用范围 | |---|---|---| | `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 | | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | @@ -254,18 +265,26 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 + NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 ## Desktop 运行时准备 +Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。 + +打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。 + Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 + macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 @@ -280,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留 ## 开发备注 -无。 +XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。 diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css index 591a82b6ab..dc721b8a68 100644 --- a/apps/desktop/renderer/mandatory-update.css +++ b/apps/desktop/renderer/mandatory-update.css @@ -1,16 +1,19 @@ -#status, #version { margin-top: 12px; color: #555962; } +#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); } #status:empty, #version:empty { display: none; } -#error { margin-top: 12px; color: #d93025; } -#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; } -#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; } +#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); } +#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; } +#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; } #update, #refresh { border-radius: 100px; } -#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; } -#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; } -#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; } -#copy-message { color: #d93025 !important; } +#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; } +#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); } +#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; } +#copy-message { color: var(--dsw-alias-state-error-primary) !important; } #manual-copy { display: block; margin-top: 8px; } -#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; } +#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); } -#progress::-webkit-progress-bar { background: #e6e7eb; } -#progress::-webkit-progress-value { background: #0f1115; } -#progress::-moz-progress-bar { background: #0f1115; } +#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); } +#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); } +#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); } +@media (hover: hover) { + #actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +} diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index f009ab8a37..d4d1747cf9 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -5,10 +5,12 @@ + +

diff --git a/apps/desktop/renderer/shell-theme.js b/apps/desktop/renderer/shell-theme.js new file mode 100644 index 0000000000..bbc12f381b --- /dev/null +++ b/apps/desktop/renderer/shell-theme.js @@ -0,0 +1,6 @@ +/** Shell documents follow Electron's app-resolved palette before displaying their content. */ +const palette = window.matchMedia('(prefers-color-scheme: dark)') +const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) } +applyPalette() +palette.addEventListener('change', applyPalette) +window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true }) diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index 98183989a5..dd50411d46 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -1,31 +1,37 @@ :root { - color-scheme: light; + color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; - color: #0f1115; background: transparent; } * { box-sizing: border-box; } -body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } -body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } -main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } +body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; } +body[data-ds-dark-theme] { color-scheme: dark; } +body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); } +main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; } +body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } main:focus { outline: none; } h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; } p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; } -#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; } -button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; } -.primary { background: #0f1115; color: #fff; } -.secondary { background: #f5f5f5; color: inherit; } +#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); } +button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; } +.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); } + +.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; } .close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; } -.close img { width: 14px; height: 14px; } -button:hover { filter: brightness(0.95); } -button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; } +.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; } + +button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; } details { margin-top: 12px; font-size: 13px; line-height: 20px; } -summary { cursor: pointer; color: #5b6472; } -pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +summary { cursor: pointer; color: var(--dsw-alias-label-secondary); } +pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } button:disabled { opacity: 0.5; cursor: default; } [hidden] { display: none !important; } body.update-dialog { opacity: 0; transition: opacity 150ms ease; } body.update-dialog.visible { opacity: 1; } @starting-style { body.update-dialog.visible { opacity: 0; } } @media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } } +@media (hover: hover) { + .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } + .secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); } +} diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html index 85a0f20abd..c6a913347d 100644 --- a/apps/desktop/renderer/update-dialog.html +++ b/apps/desktop/renderer/update-dialog.html @@ -5,11 +5,13 @@ + +
-

+

diff --git a/packages/client/ui-plugin-manager/locale/de.json b/packages/client/ui-plugin-manager/locale/de.json new file mode 100644 index 0000000000..16a03e8049 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plug-in-Verwaltungsschnittstelle", + "description": "Plug-in-Pakete anzeigen und installieren sowie Vorgänge aktivieren, deaktivieren und wiederholen." + } +} diff --git a/packages/client/ui-plugin-manager/locale/es.json b/packages/client/ui-plugin-manager/locale/es.json new file mode 100644 index 0000000000..3ca20e033d --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de gestión de complementos", + "description": "Vea e instale paquetes de complementos y habilite, deshabilite y reintente operaciones." + } +} diff --git a/packages/client/ui-plugin-manager/locale/fr.json b/packages/client/ui-plugin-manager/locale/fr.json new file mode 100644 index 0000000000..48bf4551cd --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de gestion des plug-ins", + "description": "Affichez et installez les packages de plug-ins, puis activez, désactivez et réessayez les opérations." + } +} diff --git a/packages/client/ui-plugin-manager/locale/ja.json b/packages/client/ui-plugin-manager/locale/ja.json new file mode 100644 index 0000000000..dea565954e --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン管理 UI", + "description": "プラグインパッケージの確認、インストール、有効化、無効化、再試行を行います。" + } +} diff --git a/packages/client/ui-plugin-manager/locale/pt.json b/packages/client/ui-plugin-manager/locale/pt.json new file mode 100644 index 0000000000..f1e246b3a2 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de gerenciamento de plug-ins", + "description": "Visualize e instale pacotes de plug-ins e ative, desative e repita as operações." + } +} diff --git a/packages/client/ui-plugin-manager/locale/ru.json b/packages/client/ui-plugin-manager/locale/ru.json new file mode 100644 index 0000000000..155d905c69 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс управления плагинами", + "description": "Просмотрите и установите пакеты подключаемых модулей, а также включите, отключите и повторите операции." + } +} diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..c8637e13ca 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-manager", "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -55,6 +55,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", "clsx": "^2.1.1", diff --git a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx index 90a2f07045..207d3783ad 100644 --- a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx +++ b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx @@ -38,9 +38,7 @@ export function BundleDetailSection(props: BundleDetailSectionProps): ReactNode const { name, version, fromNpm, language, details, t, load, retry, image } = props const key = bundleDetailKey(name, version, language) const state = details[key] - // The key names the read completely; a read the controller forgot, after a profile change, starts again. - const missing = state === undefined - useEffect(() => { if (missing) load(name, version, language, fromNpm) }, [key, missing]) + useEffect(() => { load(name, version, language, fromNpm) }, [key, details]) const labels = useDetailDocumentLabels({ title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'), codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('detailFootnotes'), diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 4cc7c0246f..6e779d10df 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1,25 +1,150 @@ -/* Plugin manager page: a centred column with its header, notices, and the package cards. */ +/* Plugin manager page: the main header and content share one scroller; the header retains its flow height. + The More list has a separate fixed head, and detail views scroll with their content. + + Hover affordances sit under @media (hover: hover): a tap latches :hover, which + would strand a card, row, or control in its pointer-only state. Text fields take + a 16px floor under @media (pointer: coarse): iOS Safari zooms the visual viewport + for a focused field under 16px and leaves the page pannable afterwards. */ .page { + --plugin-page-inset: clamp(24px, 4vw, 48px); + /* Every head row's top inset; the collapsed page bar narrows it. */ + --plugin-page-top-inset: 28px; + --bundle-progress-size: 14px; + --bundle-toggle-gap: 8px; + + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + height: 100%; + /* Inline inset only: the 28px top inset belongs to the head row + (`.pageHead`, `.detailTop`, `.moreHead`). macOS window dragging is owned by a row's own box, + so an inset above that box leaves a top strip no drag region covers. */ + padding: 0 var(--plugin-page-inset); + color: var(--dsw-alias-label-primary); +} + +.page > * { + flex: none; + width: 100%; + max-width: 960px; +} + +.pageBody > .pageBar { + container: plugin-page-bar / inline-size; + align-self: flex-start; + position: relative; + display: flex; + flex-direction: column; + width: calc(100% + 2 * var(--plugin-page-inset) + var(--plugin-page-scrollbar-width, 0px)); + max-width: none; + margin-inline: calc(-1 * var(--plugin-page-inset)); + box-sizing: border-box; + padding-inline: var(--plugin-page-inset); +} + +.pageBar > .pageHead { + align-self: center; + width: 100%; + max-width: 960px; +} + +/* Spans the page's full width, so its scrollbar sits at the page edge, and re-centres the column inside. */ +.page > .pageBody { + flex: 1; + min-height: 0; + width: calc(100% + 2 * var(--plugin-page-inset)); + max-width: none; + margin-inline: calc(-1 * var(--plugin-page-inset)); display: flex; flex-direction: column; align-items: center; gap: 32px; box-sizing: border-box; - height: 100%; - overflow: auto; - /* Inline and bottom insets only: the 28px top inset belongs to the head row - (`.pageHead`, `.detailTop`). macOS window dragging is owned by a row's own box, - so an inset above that box leaves a top strip no drag region covers. */ - padding: 0 clamp(24px, 4vw, 48px) 48px; - color: var(--dsw-alias-label-primary); + padding: 0 var(--plugin-page-inset) 48px; + overflow-x: hidden; + overflow-y: auto; + position: relative; + scroll-padding-top: var(--plugin-header-scroll-padding, 0px); } -.page > * { +/* The initial list inset scrolls away; the scroll viewport meets the fixed bar directly. */ +.page > :not(.pageBody) + .pageBody { + padding-top: 32px; +} + +.pageBody > * { + flex: none; width: 100%; max-width: 960px; } +/* Endpoint copies never enter the scroll layout or the accessibility tree. */ +.pageBody > .pageBar[data-measuring] { + position: absolute; + top: 0; + left: 0; + margin: 0; + visibility: hidden; + pointer-events: none; +} + +.pageBody > .pageBar[data-natural] { + position: sticky; + top: 0; + z-index: 2; + pointer-events: none; +} + +.pageBar[data-natural] .pageHead { + position: relative; + height: var(--plugin-header-rest-height); + clip-path: inset(0 0 max(0px, calc(var(--plugin-header-rest-height) - var(--plugin-header-paint-height))) 0); + pointer-events: auto; +} + +.pageBar[data-natural] .pageHeading { + display: contents; +} + +.pageBar[data-natural] [data-plugin-header-part] { + position: absolute; + left: var(--plugin-part-x); + top: var(--plugin-part-y); + width: var(--plugin-part-width); + margin: 0; + pointer-events: auto; +} + +.pageBar[data-natural] .pageTabs { + z-index: 2; + pointer-events: none; +} + +.pageBar[data-natural] .pageTabList { + pointer-events: auto; +} + +.pageBar[data-natural]::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: var(--plugin-header-paint-height, var(--plugin-header-rest-height)); + background: var(--dsw-alias-bg-base); + pointer-events: none; +} + +.pageBar[data-natural][data-compact]::after { + content: ''; + position: absolute; + inset-inline: 0; + top: 0; + height: var(--plugin-header-compact-height); + box-sizing: border-box; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + .pageHead { display: flex; flex-wrap: wrap; @@ -29,7 +154,7 @@ box-sizing: border-box; /* The page's former top inset: the row starts at the window's top edge, so the whole strip above the page content drags the window. */ - padding-top: 28px; + padding-top: var(--plugin-page-top-inset); } /* macOS hiddenInset titlebar: the frame's top clearance keeps the head below @@ -37,12 +162,13 @@ owns the window drag over its own box — clearance included — so this page's top strip drags without a frame band. */ :global([data-platform='darwin']) .pageHead { - padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); } .pageHeading { min-width: 0; max-width: 100%; + order: 1; } .pageTitle { @@ -79,6 +205,7 @@ gap: 8px 16px; min-width: 0; white-space: nowrap; + order: 2; } .addMenu { @@ -164,11 +291,16 @@ color: color-mix(in srgb, var(--dsw-alias-label-secondary) 60%, var(--dsw-alias-label-tertiary)); } -.groupMore:hover, .groupMore:focus-visible { color: var(--dsw-alias-label-secondary); } +@media (hover: hover) { + .groupMore:hover { + color: var(--dsw-alias-label-secondary); + } +} + .moreHead { align-items: center; gap: 8px; @@ -180,19 +312,6 @@ color: var(--dsw-alias-label-secondary); } -/* The group's explanation, folded behind a hover/focus tooltip. */ -.groupInfo { - display: inline-flex; - align-items: center; - align-self: center; - color: var(--dsw-alias-label-caption); -} - -.groupInfo:hover, -.groupInfo:focus-visible { - color: var(--dsw-alias-label-secondary); -} - /* Center the status label geometrically: beside larger titles the capsule's line-box offset shows. */ .statusTag { height: 18px; @@ -311,8 +430,16 @@ position: relative; } -.cardLink:hover { - background: var(--dsw-alias-interactive-bg-hover); +/* Press feedback carries the tap: with hover fenced off, a card would otherwise + read as inert between touch-down and release. */ +.cardLink:active { + background: var(--dsw-alias-interactive-bg-active); +} + +@media (hover: hover) { + .cardLink:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .cardOpen { @@ -380,9 +507,9 @@ .skeletonDescription { width: min(280px, 85%); } .skeletonIcon { border-color: transparent; } -/* Keep text-column metrics while leaving the action area blank. */ +/* Switch.module.css owns the 36px switch; ongoing StateDot occupies the reserved progress size. */ .skeletonActions { - width: 36px; + width: calc(36px + var(--bundle-toggle-gap) + var(--bundle-progress-size)); height: 20px; } @@ -405,6 +532,29 @@ gap: 8px; } +.bundleToggle { + display: inline-flex; + align-items: center; + gap: var(--bundle-toggle-gap); +} + +.toggleProgress { + display: inline-flex; + width: var(--bundle-progress-size); + height: var(--bundle-progress-size); +} + +.toggleProgress > svg { width: 100%; height: 100%; } + +.progressAnnouncement { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + /* The toolbar's icon action, round like the message-row actions. */ .iconButton { display: inline-flex; @@ -419,9 +569,15 @@ cursor: pointer; } -.iconButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); +.iconButton:active:not(:disabled) { + background: var(--dsw-alias-interactive-bg-active); +} + +@media (hover: hover) { + .iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + } } .iconButton:disabled { @@ -499,10 +655,16 @@ cursor: pointer; } -.deleteButton:hover:not(:disabled) { +.deleteButton:active:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover)); } +@media (hover: hover) { + .deleteButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover)); + } +} + .deleteButton:disabled { opacity: 0.5; cursor: default; @@ -634,8 +796,10 @@ cursor: pointer; } -.guideToggle:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .guideToggle:hover { + color: var(--dsw-alias-label-primary); + } } .guideChevron { @@ -765,8 +929,10 @@ cursor: pointer; } -.registryToggle:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .registryToggle:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + } } .registryToggle:disabled { @@ -819,8 +985,10 @@ cursor: pointer; } -.registryOption:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .registryOption:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* An offered registry is one row: the radio beside its title and the line under it. */ @@ -954,9 +1122,11 @@ label.registryOption { cursor: pointer; } -.wizardClose:hover { - background: var(--dsw-alias-bg-layer-3); - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .wizardClose:hover { + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-primary); + } } .wizardHero { @@ -1213,9 +1383,9 @@ label.registryOption { .detailTop, .moreHead { display: flex; - /* The same 28px top inset `.pageHead` carries, so the row starts at the window's + /* The same top inset `.pageHead` carries, so the row starts at the window's top edge and the strip above the detail content drags the window. */ - padding-top: 28px; + padding-top: var(--plugin-page-top-inset); } .detailTop { @@ -1224,7 +1394,7 @@ label.registryOption { :global([data-platform='darwin']) .detailTop, :global([data-platform='darwin']) .moreHead { - padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); } .crumb { @@ -1240,8 +1410,10 @@ label.registryOption { cursor: pointer; } -.crumb:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .crumb:hover { + color: var(--dsw-alias-label-primary); + } } .crumb:focus-visible { @@ -1466,9 +1638,11 @@ label.registryOption { cursor: pointer; } -.rowOpen:hover .rowId { - text-decoration: underline; - text-underline-offset: 3px; +@media (hover: hover) { + .rowOpen:hover .rowId { + text-decoration: underline; + text-underline-offset: 3px; + } } .rowOpenIcon { @@ -1476,8 +1650,10 @@ label.registryOption { color: var(--dsw-alias-label-tertiary); } -.rowOpen:hover .rowOpenIcon { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .rowOpen:hover .rowOpenIcon { + color: var(--dsw-alias-label-primary); + } } .rowState { @@ -1509,11 +1685,64 @@ label.registryOption { display: none; } -/* Installed and contributed tabs under the page header, aligned with the title; the negative margin halves - the page's 32px row gap, so the tabs read as part of the header. */ +/* Installed and contributed tabs under the page header at rest; the scrolled bar + pulls them onto the actions' row. */ .pageTabs { display: flex; - margin-top: -16px; + order: 3; + flex: 1 1 100%; +} + +/* Keep spacing inside the compact bar, above its divider rather than outside the scroll viewport. */ +.pageBar[data-compact]:not([data-natural]) { + --plugin-page-top-inset: 16px; + + padding-bottom: 16px; +} + +/* With the sidebar open, native window controls sit in the sidebar rather than above the page. */ +:global([data-platform='darwin']) .pageBar[data-compact]:not([data-natural]) .pageHead { + padding-top: var(--plugin-page-top-inset); +} + +/* Collapsing the sidebar moves those controls above the page; the compact bar then clears them. */ +:global([data-platform='darwin']) :global([data-sidebar-collapsed]) .pageBar[data-compact]:not([data-natural]) .pageHead { + --plugin-page-top-inset: 8px; + + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); +} + +/* The collapsed bar's divider spans the page like the scrolled list and its scrollbar. */ +.pageBar[data-compact]:not([data-natural])::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); + pointer-events: none; +} + +.pageBar[data-compact]:not([data-natural]) .pageHead { + align-items: center; +} + +.pageBar[data-compact]:not([data-natural]) .pageIntro { + display: none; +} + +/* Visually hidden rather than removed, so the page keeps its only heading for assistive technology. */ +.pageBarWithTabs[data-compact]:not([data-natural]) .pageHeading { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.pageBarWithTabs[data-compact]:not([data-natural]) .pageTabs { + order: 1; + flex: 0 0 auto; } .pageTabList { @@ -1521,6 +1750,56 @@ label.registryOption { min-width: 240px; } +@container plugin-page-bar (width < 600px) { + .pageBarWithTabs .pageHead { + align-items: center; + } + + .pageBarWithTabs .pageHeading { + flex-basis: 100%; + } + + .pageBarWithTabs .pageTabs { + order: 2; + flex: 0 0 auto; + } + + .pageBarWithTabs .toolbar { + order: 3; + } + + .toolbarLabel { + display: none; + } + + .pageHead .addButton, + .pageHead .addPrimary { + width: 32px; + min-width: 32px; + padding-inline: 0; + } + + /* The icon actions keep their width; a wrapped update note stays within the head. */ + .toolbar { + flex-shrink: 0; + max-width: 100%; + } + + .updatesNote { + order: 3; + flex-basis: 100%; + min-width: 0; + white-space: normal; + } +} + +/* Keep the usual tab width until the icon actions and their gaps need its space. */ +@container plugin-page-bar (width < 372px) { + .pageTabList { + min-width: 0; + } +} + /* The Installed groups keep the page's row gap inside their tab panel. */ .installedPanel { display: flex; @@ -1577,8 +1856,10 @@ button.versionSummary { cursor: pointer; } -button.versionSummary:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + button.versionSummary:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .versionSummary[aria-expanded='true'] { @@ -1942,3 +2223,11 @@ button.versionSummary:focus-visible { display: none; } } + +@media (pointer: coarse) { + .installField input[type='text'], + .registryCustomField, + .partsFilter input { + font-size: 16px; + } +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 3772bda0f6..f9493d71a0 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,11 +21,13 @@ import { type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { EXTENSION_BUNDLES, HIDDEN_BUNDLES, MORE_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { createNavigationStore } from './navigation-store.ts' +import type { PluginHeaderLayoutFace } from './header-scroll.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts' import { - asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate, + bundleBusy, asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type PluginManagerState, type RegistryChoice, type VersionCheck, } from './manager-store.ts' @@ -45,7 +47,7 @@ export type PluginManagerPageProps = | 'plugins.add.actions' | 'plugins.tab' | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > - & InjectFace + & InjectFace & PropsStore> /** The page's slot renderer, narrowed to the configuration slots. */ @@ -65,10 +67,12 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { const onDismiss = (): void => { setOpen(false) } return ( - + + + = 0) return alwaysHeld + const ranked = EXTENSION_BUNDLES.indexOf(name) + if (ranked >= 0) return ranked const heldBack = MORE_BUNDLES.indexOf(name) return heldBack < 0 ? EXTENSION_BUNDLES.length : EXTENSION_BUNDLES.length + 1 + heldBack } @@ -410,13 +384,20 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: { readonly onSetEnabled: (enabled: boolean) => void }): ReactNode { return ( - + + + {busy ? t('applyingChange', { name: title }) : ''} + {busy ? : null} + + + ) } @@ -568,6 +549,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, newer, onOpen, on end={} /> + {pkg.error?.code === 'unsupported-platform' &&

{managementText(pkg.error, t)}

} ) } @@ -1326,7 +1308,7 @@ function InstallDialog({ ) } - const heading = t(SCREEN_TITLE_KEYS[phase]) + const heading = t(phase === 'done' && install.subject?.bundled ? 'installBundledTitle' : SCREEN_TITLE_KEYS[phase]) const pending = isInstallPending(phase) const cancellable = phase === 'starting' || phase === 'running' || phase === 'unconfirmed' const stoppable = cancellable || phase === 'failed' || phase === 'unknown' @@ -1386,6 +1368,7 @@ function InstallDialog({ : }

{heading}

+ {phase === 'done' && subject?.bundled ?

{t('installBundledDescription')}

: null} {phase === 'failed' ?

{failureText(install.failure, t, install)}

: null} {attemptLine === null ? null :

{attemptLine}

} {uncertaintyText === null ? null :

{uncertaintyText}

} @@ -1430,10 +1413,12 @@ function InstallDialog({ ?

{t('installDoneApproved', { names: install.approvedBuilds.join(', ') })}

: null}
- + {subject?.bundled ? null : ( + + )} {uncertainty === 'result' ? : null} {pending ? ( @@ -1538,6 +1523,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab]) const tabsId = useId() const [activation, setActivation] = useState(null) + const [headerRef] = useState(props.createHeaderRef) useEffect(() => { ensure() }, [ensure]) // Installation highlights visible cards without changing the user's current page. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -1550,15 +1536,15 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { }, [highlight, clearHighlight]) const noticeLine = state.notice === null || state.notice.kind === 'refresh-failed' ? null : noticeText(state.notice, t) - // The page manages what the person installed, what the installation ships for them to switch on, and a - // selected name the Host cannot read; the installation's other bundles are inspected in the Settings - // Plugins section's Plugin list tab. - const listed = state.packages.filter(pkg => !BUILTIN_PROFILE_BUNDLES.has(pkg.name) + // The page omits profile bundles and persistent terminals. Other installed or offered bundles and + // unreadable selections remain manageable; Settings' Plugin list inspects the full Host inventory. + const listed = state.packages.filter(pkg => !HIDDEN_BUNDLES.has(pkg.name) + && (pkg.listedByDefault !== false || pkg.enabled || pkg.installed) && (pkg.installed || pkg.official || pkg.error !== undefined)) const mine = listed.filter(pkg => !pkg.official) const official = listed.filter(pkg => pkg.official) - // Both lists read one order over every offered bundle: the main list holds the always-held ones plus the held-back - // ones the profile enabled, and the More list holds all of them, so enabling a bundle from the More list pins it + // Both lists read one order over the eligible bundles: the main list holds the always-held ones plus the held-back + // ones the profile enabled, and the More list holds the eligible ones, so enabling a bundle from the More list pins it // to the main list without moving it among its neighbours there. const ordered = orderOptionalBundles(official) const pinned = new Set(official.filter(pinnedToMainList).map(pkg => pkg.name)) @@ -1572,7 +1558,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const onInstalled = tab === INSTALLED_TAB const showsCards = !onInstalled || (openPkg === undefined && openItem === undefined) const detailFromMore = (view.kind === 'package' || view.kind === 'row') - && (view.from === 'more' || (MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) + && (view.from === 'more' || (openPkg !== undefined && MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) const showsMoreList = onInstalled && showsCards && (view.kind === 'more' || detailFromMore) const showsMainList = showsCards && !showsMoreList const installedCards = onInstalled && showsCards @@ -1595,8 +1581,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { }) const updates = updatesNote(mine, state.versions, t) const updatePkg = state.update === null ? undefined : state.packages.find(pkg => pkg.name === state.update?.packageName) - const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) || state.update?.packageName === pkg.name - || (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) + const packageBusy = (pkg: PackageView): boolean => bundleBusy(state, pkg.name) const packageCard = (pkg: PackageView): ReactNode => ( - {showsMainList - ? ( -
-
-

{t('title')}

-
- {t('intro')} - - - -
-
- -
- ) - : null} - {showsMainList && tabs.length > 0 - ? ( -
- { setTab(next) }} - items={[ - { value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` }, - ...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })), - ]} - /> -
- ) - : null} - {!onInstalled - ? ( -
- {renderSlot('plugins.tab', { - install: props.installFrom, - openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, - }, { only: tab })} -
- ) - : null} {showsMoreList ? (
) : null} - {installedCards && !loaded ? ( -
- {state.status === 'loading' ? : null} - {state.status === 'unavailable' ? ( -

- {t('unavailable')} -

- ) : null} -
- ) : null} - {state.notice === null || noticeLine === null - ? null - : ( - } - holdMs={toastHoldMs(noticeLine)} - onDone={props.dismissNotice} - /> - )} - {!showsCards && state.status === 'error' && !refreshing - ? ( -
-

- {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} -

- -
- ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow !== undefined - ? ( - { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} - /> - ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow === undefined - ? ( - row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} - configured={ledger.bundles.has(openPkg.name)} - configure={configure(openPkg)} - renderSlot={renderSlot} - inMore={backToMore} - detail={( - - )} - onBack={() => { - if (returnTab !== undefined) setTab(returnTab) - setView({ kind: backToMore ? 'more' : 'list' }) - }} - onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} - onUninstall={() => { props.uninstall(openPkg.name) }} - onUpdate={() => { props.update(openPkg.name) }} - onSetRowEnabled={setRowEnabled} - versions={state.versions[openPkg.name]} - onEnsureVersions={props.ensureVersions} - onCheckVersions={() => { props.checkVersions(openPkg.name) }} - onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }} - /> - ) - : null} - {onInstalled && loaded && openItem !== undefined - ? { setView({ kind: 'list' }) }} /> - : null} - {loaded && installedCards - ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' - ?

{t('empty')}

- : ( -
- {showsMoreList ? ( -
-
    {more.map(packageCard)}
-
- ) : ( - <> - {renderGroup('basic', t('basicTitle'), basicCards)} - {renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))} - {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} - - )} - {/* A failed package read trails the groups it left incomplete: right under the groups on a - first-load failure, and after the kept cards when a refresh fails over stale data. */} - {state.status === 'error' && !refreshing - ? ( -
-

- {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} -

- +
+ {showsMainList + ? ( +
0 ? ` ${css.pageBarWithTabs}` : ''}`} + data-plugin-bar + ref={headerRef} + > +
+
+

{t('title')}

+
+ {t('intro')} + + +
- ) - : null} +
+ + {tabs.length > 0 + ? ( +
+ { setTab(next) }} + items={[ + { value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` }, + ...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })), + ]} + /> +
+ ) + : null} +
) - : null} - {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} - {installedCards && activated !== undefined && !state.install.open - ? renderSlot('plugins.bundle.activation', { - packageName: activated.name, - onDismiss: () => { setActivation(null) }, - onOpenDetails: () => { - setActivation(null) - setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} }) - }, - }, { entryKey: activated.name }) : null} + : null} + + {!onInstalled + ? ( +
+ {renderSlot('plugins.tab', { + install: props.installFrom, + openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, + }, { only: tab })} +
+ ) + : null} + {installedCards && !loaded ? ( +
+ {state.status === 'loading' ? : null} + {state.status === 'unavailable' ? ( +

+ {t('unavailable')} +

+ ) : null} +
+ ) : null} + {state.notice === null || noticeLine === null + ? null + : ( + } + holdMs={toastHoldMs(noticeLine)} + onDone={props.dismissNotice} + /> + )} + {!showsCards && state.status === 'error' && !refreshing + ? ( +
+

+ {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} +

+ +
+ ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow !== undefined + ? ( + { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} + /> + ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow === undefined + ? ( + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} + configured={ledger.bundles.has(openPkg.name)} + configure={configure(openPkg)} + renderSlot={renderSlot} + inMore={backToMore} + detail={( + + )} + onBack={() => { + if (returnTab !== undefined) setTab(returnTab) + setView({ kind: backToMore ? 'more' : 'list' }) + }} + onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} + onUninstall={() => { props.uninstall(openPkg.name) }} + onUpdate={() => { props.update(openPkg.name) }} + onSetRowEnabled={setRowEnabled} + versions={state.versions[openPkg.name]} + onEnsureVersions={props.ensureVersions} + onCheckVersions={() => { props.checkVersions(openPkg.name) }} + onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }} + /> + ) + : null} + {onInstalled && loaded && openItem !== undefined + ? { setView({ kind: 'list' }) }} /> + : null} + {loaded && installedCards + ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' + ?

{t('empty')}

+ : ( +
+ {showsMoreList ? ( +
+
    {more.map(packageCard)}
+
+ ) : ( + <> + {renderGroup('basic', t('basicTitle'), basicCards)} + {renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))} + {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} + + )} + {/* A failed package read trails the groups it left incomplete: right under the groups on a + first-load failure, and after the kept cards when a refresh fails over stale data. */} + {state.status === 'error' && !refreshing + ? ( +
+

+ {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} +

+ +
+ ) + : null} +
+ ) + : null} + {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} + {installedCards && activated !== undefined && !state.install.open + ? renderSlot('plugins.bundle.activation', { + packageName: activated.name, + onDismiss: () => { setActivation(null) }, + onOpenDetails: () => { + setActivation(null) + setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} }) + }, + }, { entryKey: activated.name }) : null} +
{state.update === null ? null : ( value.status === 'ready'))) } /** Stop publishing late settlements. */ @@ -103,7 +103,7 @@ export class BundleDetailController { const key = bundleDetailKey(name, version, language) const read = ++this.sequence this.reads.set(key, read) - this.publish(key, { status: 'loading' }) + if (this.store.getSnapshot()[key]?.status !== 'ready') this.publish(key, { status: 'loading' }) // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. const { remote } = this.ctx const current = (): boolean => !this.disposed && this.reads.get(key) === read diff --git a/packages/client/ui-plugin-manager/src/client/header-scroll.ts b/packages/client/ui-plugin-manager/src/client/header-scroll.ts new file mode 100644 index 0000000000..38c74d70ee --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/header-scroll.ts @@ -0,0 +1,163 @@ +/** Scroll-linked header geometry keeps both responsive layouts without changing the list's flow height. */ + +/** Entry-owned measurement callbacks for the plugin page. */ +export interface PluginHeaderLayoutFace { + /** Create one element-ref lifecycle for each mounted page header. */ + createHeaderRef: () => (element: HTMLDivElement | null) => void +} + +interface Box { + x: number + y: number + width: number + height: number +} + +interface Part { + element: HTMLElement + from: Box + to: Box + distance: number + clipStart?: number + clipEnd?: number +} + +const PARTS = ['title', 'intro', 'toolbar', 'tabs'] as const + +/** + * Measure the existing resting and compact CSS layouts in an inert, out-of-flow copy. + * Their independent flex wrapping requires pixel measurements; interpolating only positioned + * contents leaves the real header's resting footprint and the scroll range unchanged. + * @returns A ref callback that disposes its element observers and scroll listener on detachment. + */ +export function createPluginHeaderRef(): (element: HTMLDivElement | null) => void { + let dispose: (() => void) | undefined + return (element) => { + dispose?.() + dispose = element === null ? undefined : attach(element) + } +} + +function attach(bar: HTMLDivElement): () => void { + const body = bar.parentElement as HTMLElement + const parts: Part[] = [] + let distance = 1 + let restHeight = 0 + let compactHeight = 0 + let disposed = false + + const scroll = (): void => { + const offset = Math.max(0, body.scrollTop) + bar.toggleAttribute('data-compact', offset >= distance) + bar.toggleAttribute('data-scrolled', offset > 0) + for (const part of parts) { + const progress = Math.min(1, offset / part.distance) + part.element.style.translate = `${(part.to.x - part.from.x) * progress}px ${(part.to.y - part.from.y) * progress}px` + part.element.style.width = `${part.from.width + (part.to.width - part.from.width) * progress}px` + if (part.clipStart !== undefined && part.clipEnd !== undefined) { + const clip = Math.max(0, Math.min(1, (offset - part.clipStart) / (part.clipEnd - part.clipStart))) + part.element.style.clipPath = `inset(${clip * 100}% 0 0)` + } else { + part.element.style.removeProperty('clip-path') + } + if (part.element.dataset.pluginHeaderPart === 'intro') { + part.element.inert = offset >= (part.clipEnd ?? part.distance) + } + } + bar.style.setProperty('--plugin-header-paint-height', `${Math.max(compactHeight, restHeight - offset)}px`) + } + + const measure = (): void => { + if (disposed) return + body.style.setProperty('--plugin-page-scrollbar-width', `${body.offsetWidth - body.clientWidth}px`) + const clone = bar.cloneNode(true) as HTMLDivElement + clone.removeAttribute('data-natural') + clone.removeAttribute('data-compact') + clone.removeAttribute('data-scrolled') + clone.setAttribute('data-measuring', '') + clone.setAttribute('aria-hidden', 'true') + clone.inert = true + clone.style.width = `${bar.getBoundingClientRect().width}px` + for (const element of clone.querySelectorAll('[id], [data-plugin-header-part]')) { + element.removeAttribute('id') + if (element.hasAttribute('data-plugin-header-part')) element.removeAttribute('style') + } + body.append(clone) + const snapshot = (): { height: number; boxes: Map } => { + const head = (clone.querySelector('header') as HTMLElement).getBoundingClientRect() + const boxes = new Map() + for (const name of PARTS) { + const element = clone.querySelector(`[data-plugin-header-part="${name}"]`) + if (element === null) continue + const box = element.getBoundingClientRect() + boxes.set(name, { x: box.x - head.x, y: box.y - head.y, width: box.width, height: box.height }) + } + return { height: clone.getBoundingClientRect().height, boxes } + } + let resting: ReturnType + let compact: ReturnType + try { + resting = snapshot() + clone.setAttribute('data-compact', '') + compact = snapshot() + } finally { + clone.remove() + } + restHeight = resting.height + compactHeight = compact.height + distance = Math.max(1, restHeight - compactHeight) + parts.length = 0 + const hasTabs = resting.boxes.has('tabs') + const compactTitle = compact.boxes.get('title') as Box + for (const name of PARTS) { + const from = resting.boxes.get(name) + if (from === undefined) continue + const element = bar.querySelector(`[data-plugin-header-part="${name}"]`) as HTMLElement + let to = compact.boxes.get(name) as Box + let clipStart: number | undefined + let clipEnd: number | undefined + if (name === 'intro' || (name === 'title' && hasTabs)) { + to = { ...from, y: -from.height } + if (name === 'intro' && !hasTabs) { + clipStart = Math.max(0, from.y - compactTitle.y - compactTitle.height) + clipEnd = clipStart + from.height + to.y = from.y - clipEnd + } + } + const travel = Math.max(1, Math.abs(to.y - from.y)) + distance = Math.max(distance, travel) + element.style.setProperty('--plugin-part-x', `${from.x}px`) + element.style.setProperty('--plugin-part-y', `${from.y}px`) + element.style.setProperty('--plugin-part-width', `${from.width}px`) + parts.push({ + element, from, to, distance: travel, + ...clipStart !== undefined && clipEnd !== undefined ? { clipStart, clipEnd } : {}, + }) + } + bar.style.setProperty('--plugin-header-rest-height', `${restHeight}px`) + bar.style.setProperty('--plugin-header-compact-height', `${compactHeight}px`) + bar.style.setProperty('--plugin-header-travel', `${distance}px`) + body.style.setProperty('--plugin-header-scroll-padding', `${compactHeight + 8}px`) + bar.setAttribute('data-natural', '') + scroll() + } + + measure() + const resize = new ResizeObserver(measure) + resize.observe(body) + const mutation = new MutationObserver(measure) + mutation.observe(bar.querySelector('header') as HTMLElement, { + childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'], + }) + body.addEventListener('scroll', scroll, { passive: true }) + document.fonts.addEventListener('loadingdone', measure) + return () => { + disposed = true + resize.disconnect() + mutation.disconnect() + body.removeEventListener('scroll', scroll) + document.fonts.removeEventListener('loadingdone', measure) + body.style.removeProperty('--plugin-header-scroll-padding') + body.style.removeProperty('--plugin-page-scrollbar-width') + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 410ae5cfd5..f5908227a4 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -12,26 +12,40 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the root `main` keyed slot the page registers into, declared by // ui-layout with the panel id brand, and the `sidebar.panellist` list the // entry registers into, declared by ui-sidebar. +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves // through the owning package's client-safe types subpath). import type {} from '@deepseek-ai/dsh-plugin-manager/types' import { PluginManagerPage } from './PluginManagerPage.tsx' +import { createPluginHeaderRef } from './header-scroll.ts' import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx' import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { tabLedgerSource } from './tab-ledger.ts' import { PluginManagerController } from './manager-store.ts' -import { en, zh, type PluginManagerLocaleKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, 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 { + /** Read-only management activity, independent of the visible Plugins page. */ + pluginChanges: { + /** + * Observe whether a bundle or one of its rows is being changed. + * @param packageName Bundle package name. + * @returns A snapshot source whose subscriptions belong to the caller. + */ + bundle(packageName: string): HostObservable + } + /** Cross-plugin navigation to the Plugins panel. */ pluginNavigation: { /** @@ -68,95 +82,94 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-manager: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-plugin-manager: dictionaries') const t = ctx.locale.bind(NS) const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - + ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text)) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, - inject: (): PluginRefreshToastFace => ({ - hooks: { pluginManager: face.hooks.pluginManager }, - dismissNotice: face.dismissNotice, - }), - }, PluginRefreshToast)) - ctx.slots.inject('main', function* () { - const handle = createNavigationStore(), instance = handle.create() - const store: typeof handle = { ...handle, create: () => instance } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + // Host changes made while access was withdrawn reached no subscription below. + refresh() + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }), + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => face, - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 8f9e0f549d..98bcf3ac3e 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -62,6 +62,7 @@ export const zh = { rowPhaseFailed: '异常', rowPhaseUnloading: '卸载中', enableToggle: '启用 {name}', + applyingChange: '正在应用 {name} 的更改', openDetail: '查看 {name}', backToList: '返回插件', crumbRoot: '插件', @@ -104,6 +105,8 @@ export const zh = { installProblemInvalid: '无法识别这个包名或地址:{reason}', installProblemInstalled: '该插件已安装。如需升级,请卸载后重新安装', installProblemShipped: '该插件随 DSH 提供,升级 DSH 即可获得新版本', + installBundledTitle: '插件已随 DSH 提供', + installBundledDescription: '无需下载,启用即可使用', installProblemNotFound: '未找到相关插件', installProblemNotPackage: '该路径不存在或不是有效的插件包', installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}', @@ -281,6 +284,7 @@ export const zh = { reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新', reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {requirements}),运行它可能导致崩溃或数据丢失。', reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失。', + reasonUnsupportedPlatform: '此插件不支持运行 DSH 的主机操作系统', reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。', reasonOperationError: 'Host 报告了一个错误', @@ -353,6 +357,7 @@ export const en = { rowPhaseFailed: 'Problem', rowPhaseUnloading: 'Unloading', enableToggle: 'Enable {name}', + applyingChange: 'Applying changes to {name}', openDetail: 'View {name}', backToList: 'Back to plugins', crumbRoot: 'Plugins', @@ -376,7 +381,7 @@ export const en = { installGuideFill: 'Use example', installGuideFillAria: 'Use the example {example}', installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', - installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', + installUpgradeNotice: 'On-demand official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', registryToggle: 'Registry', registryLegend: 'The npm registry the plugin is downloaded from', registryDefault: 'Default registry', @@ -395,6 +400,8 @@ export const en = { installProblemInvalid: 'This is not a package name or address that can be installed: {reason}', installProblemInstalled: 'This plugin is already installed. To upgrade it, uninstall it and install it again', installProblemShipped: 'This plugin ships with DSH; upgrading DSH updates it', + installBundledTitle: 'This plugin ships with DSH', + installBundledDescription: 'Enable it without downloading another copy', installProblemNotFound: 'No such plugin was found', installProblemNotPackage: 'The path does not exist or is not a valid plugin package', installProblemNotBundle: 'This package declares no bundle, so it cannot be installed as a plugin: {reason}', @@ -572,6 +579,7 @@ export const en = { reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.', reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {requirements}); running it may cause crashes or data loss.', reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.', + reasonUnsupportedPlatform: 'This plugin does not support the operating system running DSH.', reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.', reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.', reasonOperationError: 'The Host reported an error.', @@ -583,3 +591,1755 @@ export const en = { detailFailed: 'Could not read the details.', detailFootnotes: 'Footnotes', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'reasonUnsupportedPlatform': 'このプラグインはDSHを実行しているホストOSに対応していません。', + 'panel': 'プラグイン', + 'title': 'プラグイン', + 'intro': 'プラグインのインストール、有効化、設定', + 'infoLabel': 'プラグインについて', + 'infoDescription': '公式プラグインの設定や、その他のプラグインのインストール・管理はここで行えます。組み込みプラグインの一覧と実行状態は「設定 → 組み込みプラグイン」で確認できます。', + 'loading': 'プラグインを読み込み中…', + 'error': '一部のプラグインを読み込めませんでした。ネットワークの問題が考えられます', + 'unavailable': 'このデプロイでは管理可能なプロファイルが実行されていないため、ここでプラグインのインストールや切り替えはできません。', + 'retry': '再試行', + 'refresh': '更新', + 'refreshError': '更新に失敗しました。もう一度お試しください。', + 'empty': 'プラグインはまだインストールされていません。', + 'addPlugin': 'プラグインを追加', + 'chooseAddMethod': 'プラグインの追加方法を選択', + 'installExisting': 'サードパーティのプラグインをインストール', + 'installExistingDescription': 'npm パッケージ、Git リポジトリ、ローカルディレクトリに対応', + 'restartNotice': '変更を適用するには DSH を再起動してください。{reason}', + 'restartReasonNoHmr': 'このプロファイルでは起動時にのみ変更が適用されます。', + 'restartReasonPackageMoved': 'インストール済みパッケージの解決先が別のディレクトリになったか、変更によって DSH インストールが提供するパッケージが置き換えられました。別の場所からインストールする前に、そのパッケージをアンインストールしてください。', + 'restartReasonHostModule': '変更はDSH自身が読み込んだモジュールにも及びます。', + 'restartReasonUnmanagedPlugin': '変更はプラグイン一覧外で動作しているプラグインにも及びます。', + 'restartReasonModuleComparison': 'DSHは読み込まれたプラグインとそのファイルを比較できませんでした。', + 'overriddenNotice': '{name}は保存されましたが、優先度の高い設定が上書きしているため有効になっていません', + 'bundlesTitle': 'インストール済みプラグイン', + 'basicTitle': '組み込みプラグイン設定', + 'extensionsTitle': '実験的プラグイン', + 'moreTitle': '実験的プラグイン', + 'moreAction': 'その他', + 'tabsLabel': 'プラグインビュー', + 'installedTab': 'インストール済み', + 'statusProblem': '問題', + 'statusBeta': '実験的', + 'reasonLabel': '理由', + 'metadataError': 'パッケージメタデータのエラー: {error}', + 'versionTag': 'v{version}', + 'partsLabel': 'コンポーネント', + 'sourceTitle': 'ソース', + 'sourceSpec': 'コードソース', + 'sourceBuiltIn': '組み込み', + 'sourceVersion': '現在のバージョン', + 'partsEmpty': 'このプラグインパックにはコンポーネントが含まれていません。', + 'partsCountTotal': '計{count}件', + 'partsCountRunning': '{count}件稼働中', + 'partsCountOff': '{count}件オフ', + 'partOff': 'オフ', + 'partsCountFailed': '{count}件失敗', + 'partsFilter': 'コンポーネントを絞り込む', + 'partsFilterEmpty': '一致するコンポーネントはありません。', + 'partToggle': 'コンポーネント{name}を有効化', + 'rowPhasePending': '依存関係を待機中', + 'rowPhaseLoading': '読み込み中', + 'rowPhaseActive': '稼働中', + 'rowPhaseFailed': '問題', + 'rowPhaseUnloading': 'アンロード中', + 'applyingChange': '{name} の変更を適用中', + 'installBundledTitle': 'このプラグインは DSH に同梱されています', + 'installBundledDescription': '追加のダウンロードなしで有効にできます', + 'enableToggle': '{name}を有効化', + 'openDetail': '{name}を表示', + 'backToList': 'プラグイン一覧に戻る', + 'crumbRoot': 'プラグイン', + 'backToPackage': '{name}に戻る', + 'configureRow': '{name}を設定', + 'rowStateIdle': '未稼働', + 'uninstall': 'アンインストール', + 'uninstallLabel': '{name}をアンインストール', + 'installTitle': 'プラグインを追加', + 'installDescription': 'プラグインのパッケージ名、GitHubリポジトリのアドレス、またはローカルディレクトリのパスを入力してください。', + 'installSpecLabel': 'パッケージ名またはアドレス', + 'installSpecPlaceholder': '例: dsh-plugin-whale-pet', + 'installGuideToggle': 'インストールガイドと例', + 'installGuideHide': 'ガイドを隠す', + 'installGuideIdTitle': 'プラグインのnpmパッケージ名を入力', + 'installGuideIdExample': 'dsh-plugin-whale-pet', + 'installGuideIdHint': 'プラグインパッケージ名とはnpmパッケージ名(dsh-xxxや@author/pluginなど)です。コミュニティプラグインのREADMEのインストールコマンドにあるdsh plugin addやpnpm addの後ろの部分です。', + 'installGuideExampleLabel': '例: ', + 'installGitTemplateHint': '実際のGitリポジトリのアドレスに置き換えてください。', + 'installPathTemplateHint': 'ローカルプラグインディレクトリの実際のパスに置き換えてください。', + 'installGuideFill': '例を使用', + 'installGuideFillAria': '例{example}を使用', + 'installGuideSafety': '信頼できるプラグインのみをインストールしてください。プラグインはあなたの権限で動作し、DeepSeek Harnessを破壊したりデータを漏洩させたりする可能性があります。', + 'installUpgradeNotice': 'オンデマンドの公式プラグインは、バージョンがDSHと異なる場合に更新を提供します。その他のプラグインを更新するには、アンインストールして再インストールしてください。', + 'registryToggle': 'レジストリ', + 'registryLegend': 'プラグインのダウンロード元のnpmレジストリ', + 'registryDefault': 'デフォルトレジストリ', + 'registryOfficial': '公式npmレジストリ', + 'registryNpmmirror': '中国本土ミラー', + 'registryCustom': 'カスタムアドレス', + 'registryCustomPlaceholder': 'https://npm.example.com/', + 'registryCustomHint': 'http://またはhttps://で始まる社内・プライベートのnpmレジストリのアドレスを入力してください。ログインが必要な場合は、このマシンの~/.npmrcに認証情報を保存してください。', + 'registryCustomInvalid': 'http://またはhttps://で始まるアドレスを入力してください', + 'registryListSeparator': ', ', + 'sentenceSeparator': ' ', + 'installRun': 'インストール', + 'installChecking': '確認中…', + 'installConfirmedTitle': 'プラグインをインストール', + 'installConfirmedRefused': 'このプラグインはインストールできません', + 'installProblemInvalid': 'パッケージ名またはインストール可能なアドレスではありません: {reason}', + 'installProblemInstalled': 'このプラグインはすでにインストールされています。アップグレードするには、アンインストールしてから再インストールしてください', + 'installProblemShipped': 'このプラグインはDSHに同梱されています。DSHをアップグレードすると更新されます', + 'installProblemNotFound': '該当するプラグインが見つかりません', + 'installProblemNotPackage': 'パスが存在しないか、有効なプラグインパッケージではありません', + 'installProblemNotBundle': 'このパッケージはバンドルを宣言していないため、プラグインとしてインストールできません: {reason}', + 'installProblemNetwork': 'プラグインレジストリに接続できませんでした。ネットワークを確認して再試行してください', + 'installProblemNetworkAll': 'どのレジストリにも接続できませんでした(試行: {registries})。ネットワークまたはプロキシ設定を確認するか、レジストリを変更してください', + 'installProblemUnknown': 'プラグインを照会できませんでした: {reason}', + 'installingTitle': 'プラグインをインストール中…', + 'installedTitle': 'インストール済み', + 'statusNotInstalled': '未インストール', + 'statusMissingFiles': 'パッケージ利用不可', + 'statusUpdateAvailable': 'アップデートあり', + 'sourceNotInstalled': '未インストール', + 'sourceTargetSpec': 'このDSH用のソース', + 'sourceTargetVersion': 'このDSH用のバージョン', + 'update': 'アップデート', + 'updateLabel': '{name}をアップデート', + 'versionsTitle': 'バージョン', + 'versionsRecheck': '再確認', + 'versionsChecking': 'アップデートを確認中', + 'versionsAvailable': '{version}へアップデート可能', + 'versionsPublished': '{date}に公開', + 'versionsLatest': '最新です', + 'versionsCheckedAt': '最終確認: {time}', + 'versionsBlocked': '新しいバージョンにはDSHのアップデートが必要です', + 'versionsBlockedDetail': '{version}には{peers}が必要です。このDSHは{runtime}です', + 'versionsNoneInstallable': '直接インストールできる新しいバージョンはありません', + 'versionsNoneInstallableDetail': '新しいバージョンは非推奨、プレリリース、またはバンドル未宣言です。展開して確認してください', + 'versionsFailedNetwork': 'レジストリに接続できませんでした', + 'versionsFailedOther': 'アップデートを確認できませんでした', + 'versionsDetails': '詳細', + 'versionsNewerCount': '新しいバージョン {count} 件', + 'versionsCollapse': '折りたたむ', + 'versionsInstalledTag': 'インストール済み', + 'versionsPrereleaseTag': 'プレリリース', + 'versionsDeprecatedTag': '非推奨', + 'versionsNeedsDsh': '{peers}が必要', + 'versionsNotBundle': 'このバージョンはバンドルを宣言していないためインストールできません', + 'versionsUpdateLabel': '{version}へアップデート', + 'versionsRegistry': 'レジストリ', + 'versionsCheckedAtLabel': '確認日時', + 'versionsFallbackTag': 'フォールバック', + 'versionsFallbackHint': '{registry}に接続できませんでした。リストはフォールバックレジストリからのものです', + 'versionsPrimaryTag': 'プライマリ', + 'versionsShowPrerelease': 'プレリリースを表示', + 'versionsOmitted': 'インストール済みより新しいバージョンが他に {count} 件ありますが、表示されていません', + 'versionsProblemNetwork': '到達不能', + 'versionsProblemNotFound': '見つかりません', + 'versionsProblemUnknown': '失敗', + 'updatesAvailable': 'アップデートあり: {count}', + 'updatesNone': 'すべてのプラグインは最新です', + 'updatesFailed': '一部のプラグインを確認できませんでした', + 'updateTitle': '{name}をアップデート', + 'updateSource': 'インストール元', + 'updateDescription': 'アップデートにより{from}が{to}に置き換わります。プラグインが一時停止する場合があります。インストールに失敗した場合、{from}は保持されるか再インストールされます。', + 'updateRunning': 'アップデート中', + 'updateStepChecking': '{version}を確認', + 'updateStepRemoving': '{version}を停止して削除', + 'updateStepInstalling': '{registry}から{version}をインストール', + 'updateStepRestoring': '{version}を再インストール', + 'updateDone': '{version}にアップデートしました', + 'updateDoneDetail': '新しいバージョンが実行中です。', + 'updateRestart': '{version}をインストールしました。再起動後に有効になります', + 'updateRefused': '{version}へアップデートできません', + 'updateRemoveFailed': '{version}を削除できませんでした', + 'updateRestored': 'アップデートに失敗しました。{version}を復元しました', + 'updateRestoredDetail': '{to}のインストールに失敗しました。{from}を再インストールしました。', + 'updateKept': '{version}へアップデートできませんでした', + 'updateKeptDetail': '{from}は削除されず、引き続き使用されます。{reason}', + 'updateBroken': 'アップデートに失敗しました。プラグインはインストールされていません', + 'updateBrokenDetail': '{to}のインストールに失敗し、{from}の再インストールにも失敗しました。プラグインを追加から{name}を再インストールしてください。', + 'updateLost': 'アップデート結果が不明です', + 'updateLostDetail': 'DSHへの接続が中断されました: {reason}。プラグインリストを更新してインストール済みバージョンを確認してください。', + 'updateOutput': 'インストール出力を表示', + 'updateFinish': '完了', + 'installFailedTitle': 'プラグインをインストールできませんでした', + 'installGithubFailedTitle': 'GitHubにアクセスできません', + 'installGithubTimeoutTitle': 'GitHubへの接続がタイムアウトしました', + 'installGithubFailedDescription': '別のインストール元を試してください。', + 'installUseGithubMirror': '中国本土ミラーを使用', + 'installTryAnotherWay': '別の方法を試す', + 'installPackageLabel': 'プラグインパッケージ名', + 'installEdit': '編集', + 'installEditAria': '編集に戻る', + 'installCancelAndEdit': 'インストールをキャンセルして編集に戻る', + 'installApplyingCancellationError': 'キャンセルが確認されませんでした。インストールを適用中です。結果をお待ちください。{reason}', + 'installReconcile': 'インストール状態を確認', + 'installUnknownTitle': 'インストール結果を取得できません', + 'installUnknownDescription': 'HostにこのリクエストIDの有効なインストールがありません。再試行する前にプラグイン一覧を確認してください。', + 'installResultUnconfirmed': 'インストール結果を受信できませんでした。インストール状態を確認してください。{reason}', + 'installAwaitingAcceptance': 'Hostがインストールを承認するのを待っています。承認後、キャンセルは自動的に再試行されます。', + 'installBackgroundUnknown': 'インストール結果を取得できません。プラグイン一覧を確認してください。', + 'installCancel': 'インストールをキャンセル', + 'installCloseCancels': 'インストールをキャンセルして閉じる', + 'installViewTask': 'インストールを表示', + 'installUnconfirmedTitle': 'インストール状態が未確認です', + 'installBackgroundDone': 'インストールが完了しました。インストール詳細を表示します。', + 'installBackgroundFailed': 'インストールに失敗しました。インストール詳細を表示します。', + 'installBackgroundUnconfirmed': 'インストール状態が未確認です。インストールを表示して詳細を確認してください。', + 'installBackgroundApplying': 'インストールを適用中のためキャンセルできません。インストールの進行状況を表示します。', + 'installStarting': 'インストールを準備中…', + 'installCancelling': 'インストールを停止中…', + 'installApplying': '設定を適用しています。お待ちください…', + 'installCancelledShort': 'キャンセル済み', + 'installCancelled': 'インストールをキャンセルしました。プラグインは有効化されておらず、ダウンロードしたファイルが残る場合があります', + 'installCancelUnconfirmed': 'インストールの停止が確認されていません。キャンセルを再試行するか、インストール結果をお待ちください。{reason}', + 'installEnableNow': '今すぐ有効化', + 'installDetailsShow': 'インストール詳細を表示', + 'installDetailsHide': 'インストール詳細を隠す', + 'installVersion': 'バージョン {version}', + 'installSubjectPath': 'ローカルディレクトリ', + 'installSubjectGit': 'Gitリポジトリ', + 'installSubjectTarball': 'Tarball', + 'installLocation': '{dir} にインストール', + 'installRetry': '再試行', + 'installChangeRegistry': 'レジストリを変更', + 'installAttempt': '{previous} ではパッケージを提供できませんでした。{registry} 経由で再試行しています(レジストリ {index}/{total})', + 'installAttemptBadge': '試行 {index} · {registry}', + 'installFailureNetwork': 'ネットワーク接続に失敗しました', + 'installFailureNetworkAll': 'レジストリに接続できませんでした(試行: {registries})。ネットワークまたはプロキシ設定を確認するか、レジストリを変更して再試行してください。', + 'installFailureNetworkHost': '{host} に接続できませんでした。GitHubアドレスや.tgzリンクはレジストリ経由では取得されません。このマシンが直接またはプロキシ経由で接続できる必要があります。プラグインがnpmにも公開されている場合は、パッケージ名を入力してください。', + 'installFailureNotFound': '該当するプラグインが見つかりません', + 'installFailureNoMatchingVersion': '要求に一致するバージョンがありません', + 'installFailureDiskFull': 'ディスクの容量が不足しているため、インストールを停止しました', + 'installFailurePermission': '書き込み権限がないため、プラグインをインストールできません', + 'installFailureBuildBlocked': 'インストールを続行するには、依存関係のインストールスクリプトに許可を与える必要があります', + 'installFailureBuildBlockedManual': 'pnpmがインストールスクリプトをブロックしました。pnpm-workspace.yamlのallowBuildsで許可して再試行してください', + 'installFailureIntegrity': 'ダウンロードしたパッケージの整合性チェックに失敗しました', + 'installFailureTimeout': 'インストールがタイムアウトしました', + 'installFailurePnpmMissing': 'pnpmが見つからないため、インストールできません', + 'installFailureGeneric': 'インストール中に問題が発生しました。詳細をご確認ください', + 'terminalCommandLine': 'コマンドライン {n}', + 'terminalRunning': '実行中', + 'terminalFailed': '失敗', + 'terminalDone': '完了', + 'terminalCopy': 'コピー', + 'terminalCopied': 'コピー済み', + 'terminalNoOutput': '出力なし', + 'terminalCollapseAria': '出力を折りたたむ', + 'terminalCollapse': '折りたたむ', + 'terminalExpandAria': '残り {n} 行の出力を展開', + 'terminalExpand': '… さらに {n} 行', + 'terminalExitCode': '終了コード {code}', + 'terminalSignal': 'シグナル {signal}', + 'terminalNoExitCode': '終了コードなし', + 'installDoneNothing': '新しい依存関係はなく、インストールが完了しました。', + 'installDoneRestart': 'インストール済みです。読み込むにはDSHを再起動してください。{reason}', + 'installDoneApproved': '{names} のインストールスクリプトを許可しました', + 'installDoneOtherVersion': 'pnpm のサプライチェーンセキュリティポリシー(minimumReleaseAge)では、必須の経過時間より新しく公開されたバージョンは自動的にインストールされないため、このインストールでは最新バージョン {version} ではなく {installed} を使用しました。新しく公開されたバージョンはこの待機期間を経過しておらず、検出されていない悪意のあるコードや欠陥を含む可能性があります。{version} が必要な場合は、現在のバージョンをアンインストールしてから {exact} をインストールしてそのバージョンを要求してください。', + 'installApprovalTitle': 'インストールスクリプトには許可が必要です', + 'installApprovalDescription': 'これらのパッケージには pnpm が実行しなかったインストールスクリプトがあります。', + 'installApprovalConsequence': '許可すると、スクリプトはここであなたの権限で実行され、許可はこのプロファイルに保存されます。', + 'installApprovalCaution': '信頼できるパッケージのみ許可してください。', + 'installApproveAndRetry': 'これらのスクリプトを許可して再試行', + 'installClose': '完了', + 'close': '閉じる', + 'cancel': 'キャンセル', + 'confirmUninstallTitle': '「{name}」をアンインストールしますか?', + 'confirmUninstallDescription': 'アンインストールすると、提供している機能は利用できなくなります。', + 'confirmUninstall': 'アンインストール', + 'failedEnable': '有効化できませんでした: {reason}', + 'failedDisable': '無効化できませんでした: {reason}', + 'failedUninstall': 'アンインストールできませんでした: {reason}', + 'failedRowEnable': 'コンポーネントを有効化できませんでした: {reason}', + 'failedRowDisable': 'コンポーネントを無効化できませんでした: {reason}', + 'reasonManagementRequired': 'プラグイン管理に必要です。無効化もアンインストールもできません。', + 'reasonUnaddressable': 'プロファイルパッチでこれを一意に指定できません。', + 'reasonUnknownPlugin': 'そのようなプラグインはありません。', + 'reasonInvalidSpec': '有効なパッケージ名またはアドレスを入力してください。', + 'reasonAlreadyInstalled': 'このパッケージはすでにインストールされています。別のバージョンをインストールする前にアンインストールしてください。', + 'reasonAmbiguousInstall': '依存関係の変更から、どのパッケージがインストールされたか判断できません。', + 'reasonNotBundle': 'このパッケージはバンドルを宣言していないため、プラグインとして管理できません。', + 'reasonNotRemovable': 'このパッケージはプロファイル所有ではないか、プラグイン管理に必要です。', + 'reasonStopProfile': 'このプロファイルは HMR なしで動作します。停止してから dsh plugin でパッケージをアンインストールしてください。', + 'reasonBundleInUse': '他の設定がこのバンドルのコンポーネントをまだ使用しています。先にそれらを無効にしてください。', + 'reasonStaleApproval': '保留中のスクリプト承認が変更されました。インストールをやり直して更新してください。', + 'reasonIncompatibleVersion': '{plugin} は DSH {runtime} と互換性がありません({requirements} が必要)。実行するとクラッシュやデータ損失を引き起こす可能性があります。', + 'reasonIncompatibleVersionUnnamed': 'このプラグインは実行中の DSH バージョンと互換性がありません。実行するとクラッシュやデータ損失を引き起こす可能性があります。', + 'reasonIncompatibleInstall': 'この DSH と互換性のあるプラグインバージョンをインストールしてください。', + 'reasonIncompatibleInstalled': 'アンインストールして、この DSH と互換性のあるバージョンをインストールしてください。', + 'reasonOperationError': 'Host がエラーを報告しました。', + 'detailTitle': '詳細', + 'detailLoading': '詳細を読み込み中', + 'detailTooLarge': '詳細がサイズ上限を超えているため読み込まれませんでした。', + 'detailNetwork': '詳細の取得元に接続できませんでした。', + 'detailInvalid': 'プラグインが無効な詳細ファイルを宣言しています。', + 'detailFailed': '詳細を読み込めませんでした。', + 'detailFootnotes': '脚注', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + reasonUnsupportedPlatform: 'Este complemento no es compatible con el sistema operativo que ejecuta DSH.', + panel: 'Complementos', + title: 'Complementos', + intro: 'Instalar, habilitar y configurar complementos', + infoLabel: 'Acerca de los complementos', + infoDescription: 'Configure complementos oficiales e instale o administre otros complementos aquí. Vea la lista de complementos integrados y el estado del tiempo de ejecución en Configuración → Complementos integrados.', + loading: 'Leyendo complementos…', + error: 'No se pudieron leer todos los complementos, posiblemente debido a un problema de red', + unavailable: 'Esta implementación se ejecuta sin un perfil manejable, por lo que los complementos no se pueden instalar ni cambiar aquí.', + retry: 'Reintentar', + refresh: 'Actualizar', + refreshError: 'Error al actualizar. Por favor inténtalo de nuevo.', + empty: 'Aún no hay complementos instalados.', + addPlugin: 'Agregar complemento', + chooseAddMethod: 'Elija cómo agregar un complemento', + installExisting: 'Instalar un complemento de terceros', + installExistingDescription: 'Admite paquetes npm, repositorios Git y directorios locales', + restartNotice: 'Reinicie DSH para aplicar el cambio. {reason}', + restartReasonNoHmr: 'Este perfil aplica cambios solo al inicio.', + restartReasonPackageMoved: 'Un paquete instalado ahora se resuelve en otro directorio o el cambio reemplaza un paquete que proporciona la instalación de DSH. Desinstale un paquete antes de instalarlo desde otra ubicación.', + restartReasonHostModule: 'El cambio llega a los módulos DSH propiamente cargados.', + restartReasonUnmanagedPlugin: 'El cambio afecta a un complemento que se ejecuta fuera de la lista de complementos.', + restartReasonModuleComparison: 'DSH no pudo comparar los complementos cargados con sus archivos.', + overriddenNotice: '{name} se guardó, pero una configuración de mayor prioridad lo anula, por lo que no está vigente', + bundlesTitle: 'Complementos instalados', + basicTitle: 'Configuración del complemento incorporado', + extensionsTitle: 'Complementos experimentales', + moreTitle: 'Complementos experimentales', + moreAction: 'Más', + tabsLabel: 'Vistas de complementos', + installedTab: 'Instalado', + statusProblem: 'problema', + statusBeta: 'Experimental', + reasonLabel: 'Razón', + metadataError: 'Error de metadatos del paquete: {error}', + versionTag: 'v{version}', + partsLabel: 'Componentes', + sourceTitle: 'Fuente', + sourceSpec: 'fuente del código', + sourceBuiltIn: 'Construido en', + sourceVersion: 'Versión actual', + partsEmpty: 'Este paquete de complementos no contiene componentes.', + partsCountTotal: '{count} total', + partsCountRunning: '{count} corriendo', + partsCountOff: '{count} apagado', + partOff: 'Apagado', + partsCountFailed: '{count} falló', + partsFilter: 'Componentes del filtro', + partsFilterEmpty: 'Ningún componente coincide.', + partToggle: 'Habilitar componente {name}', + rowPhasePending: 'Esperando dependencias', + rowPhaseLoading: 'Cargando', + rowPhaseActive: 'corriendo', + rowPhaseFailed: 'problema', + rowPhaseUnloading: 'Descarga', + enableToggle: 'Habilitar {name}', + applyingChange: 'Aplicar cambios a {name}', + openDetail: 'Ver {name}', + backToList: 'Volver a complementos', + crumbRoot: 'Complementos', + backToPackage: 'Volver a {name}', + configureRow: 'Configurar {name}', + rowStateIdle: 'no corriendo', + uninstall: 'Desinstalar', + uninstallLabel: 'Desinstalar {name}', + installTitle: 'Agregar complemento', + installDescription: 'Ingrese el nombre del paquete del complemento, la dirección del repositorio GitHub o la ruta del directorio local.', + installSpecLabel: 'Nombre o dirección del paquete', + installSpecPlaceholder: 'por ejemplo dsh-plugin-ballena-mascota', + installGuideToggle: 'Guía de instalación y ejemplos.', + installGuideHide: 'Ocultar la guía', + installGuideIdTitle: 'Ingrese el nombre del paquete npm del complemento', + installGuideIdExample: 'dsh-plugin-ballena-mascota', + installGuideIdHint: 'El nombre del paquete del complemento es el nombre del paquete npm (como dsh-xxx o @author/plugin): la parte después de dsh plugin add o pnpm add en el comando de instalación README de un complemento comunitario.', + installGuideExampleLabel: 'Ejemplo: ', + installGitTemplateHint: 'Reemplace esto con la dirección real del repositorio de Git.', + installPathTemplateHint: 'Reemplace esto con la ruta real a su directorio de complementos local.', + installGuideFill: 'ejemplo de uso', + installGuideFillAria: 'Utilice el ejemplo {example}', + installGuideSafety: 'Instale sólo complementos en los que confíe: se ejecutan con sus permisos y pueden dañar DeepSeek Harness o filtrar sus datos.', + installUpgradeNotice: 'Los complementos oficiales bajo demanda ofrecen actualización cuando su versión difiere de DSH. Para actualizar otros complementos, desinstálelos y vuelva a instalarlos.', + registryToggle: 'Registro', + registryLegend: 'El registro npm desde el que se descarga el complemento', + registryDefault: 'Registro predeterminado', + registryOfficial: 'Registro oficial de npm', + registryNpmmirror: 'Espejo de China continental', + registryCustom: 'Dirección personalizada', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Ingrese una dirección de registro npm interna o privada que comience con http:// o https://.. Si requiere iniciar sesión, almacene las credenciales en ~/.npmrc en esta máquina.', + registryCustomInvalid: 'Introduzca una dirección que comience con http:// o https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Instalar', + installChecking: 'Comprobando…', + installConfirmedTitle: 'Instalar complemento', + installConfirmedRefused: 'Este complemento no se puede instalar', + installProblemInvalid: 'Este no es un nombre o dirección de paquete que se pueda instalar: {reason}', + installProblemInstalled: 'Este complemento ya está instalado. Para actualizarlo, desinstálelo e instálelo nuevamente.', + installProblemShipped: 'Este complemento se envía con DSH; actualizar DSH lo actualiza', + installBundledTitle: 'Este complemento se envía con DSH', + installBundledDescription: 'Habilítelo sin descargar otra copia', + installProblemNotFound: 'No se encontró tal complemento', + installProblemNotPackage: 'La ruta no existe o no es un paquete de complemento válido', + installProblemNotBundle: 'Este paquete no declara ningún paquete, por lo que no se puede instalar como complemento: {reason}', + installProblemNetwork: 'No se pudo acceder al registro del complemento; comprueba la red y vuelve a intentarlo', + installProblemNetworkAll: 'No se pudo acceder a ningún registro (probado: {registries}); verifique la configuración de red o proxy, o cambie el registro', + installProblemUnknown: 'No se pudo buscar el complemento: {reason}', + installingTitle: 'Instalando el complemento…', + installedTitle: 'Instalado', + statusNotInstalled: 'No instalado', + statusMissingFiles: 'Paquete no disponible', + statusUpdateAvailable: 'Actualización disponible', + sourceNotInstalled: 'No instalado', + sourceTargetSpec: 'Fuente de este DSH', + sourceTargetVersion: 'Versión para este DSH', + update: 'Actualizar', + updateLabel: 'Actualización {name}', + versionsTitle: 'Versiones', + versionsRecheck: 'comprobar de nuevo', + versionsChecking: 'Buscando actualizaciones', + versionsAvailable: 'Actualización a {version} disponible', + versionsPublished: 'Publicado {date}', + versionsLatest: 'actualizado', + versionsCheckedAt: 'Última comprobación en {time}', + versionsBlocked: 'Las versiones más nuevas necesitan una actualización DSH', + versionsBlockedDetail: '{version} necesita {peers}; este DSH es {runtime}', + versionsNoneInstallable: 'No hay una versión más nueva para instalar directamente', + versionsNoneInstallableDetail: 'Las versiones más recientes están obsoletas, son versiones preliminares o no declaran ningún paquete. Ampliar para verlos', + versionsFailedNetwork: 'No se pudo acceder al registro', + versionsFailedOther: 'No se pudo buscar actualizaciones', + versionsDetails: 'Detalles', + versionsNewerCount: '{count} más nuevo', + versionsCollapse: 'Contraer', + versionsInstalledTag: 'Instalado', + versionsPrereleaseTag: 'Presentación', + versionsDeprecatedTag: 'En desuso', + versionsNeedsDsh: 'Necesita {peers}', + versionsNotBundle: 'Esta versión no declara ningún paquete y no se puede instalar.', + versionsUpdateLabel: 'Actualización a {version}', + versionsRegistry: 'Registro', + versionsCheckedAtLabel: 'Comprobado en', + versionsFallbackTag: 'Respaldo', + versionsFallbackHint: 'No se pudo alcanzar {registry}; la lista proviene de un registro alternativo', + versionsPrimaryTag: 'primero', + versionsShowPrerelease: 'Mostrar presentaciones preliminares', + versionsOmitted: '{count} no se enumeran más versiones más recientes que la instalada', + versionsProblemNetwork: 'Inalcanzable', + versionsProblemNotFound: 'No encontrado', + versionsProblemUnknown: 'Fallido', + updatesAvailable: 'Actualizaciones disponibles: {count}', + updatesNone: 'Todos los complementos están actualizados.', + updatesFailed: 'Algunos complementos no se pudieron verificar', + updateTitle: 'Actualización {name}', + updateSource: 'Instalar desde', + updateDescription: 'La actualización reemplaza {from} por {to}; el complemento puede detenerse brevemente. Si la instalación falla, {from} se conserva o se vuelve a instalar.', + updateRunning: 'Actualizando', + updateStepChecking: 'Comprobar {version}', + updateStepRemoving: 'Detener y eliminar {version}', + updateStepInstalling: 'Instale {version} desde {registry}', + updateStepRestoring: 'Instale {version} nuevamente', + updateDone: 'Actualizado a {version}', + updateDoneDetail: 'La nueva versión se está ejecutando.', + updateRestart: '{version} instalado; entra en vigor después de un reinicio', + updateRefused: 'No se puede actualizar a {version}', + updateRemoveFailed: 'No se pudo eliminar {version}', + updateRestored: 'La actualización falló; restaurado {version}', + updateRestoredDetail: '{to} no pudo instalarse; {from} se instaló nuevamente.', + updateKept: 'No se pudo actualizar a {version}', + updateKeptDetail: '{from} no se eliminó y permanece en uso. {reason}', + updateBroken: 'La actualización falló; el complemento no está instalado', + updateBrokenDetail: '{to} no se pudo instalar y la instalación de {from} nuevamente también falló. Instale {name} nuevamente desde Agregar complemento.', + updateLost: 'Se desconoce el resultado de la actualización.', + updateLostDetail: 'Se interrumpió la conexión con DSH: {reason}. Actualice la lista de complementos para ver la versión instalada.', + updateOutput: 'Mostrar resultado de instalación', + updateFinish: 'hecho', + installFailedTitle: 'No se pudo instalar el complemento', + installGithubFailedTitle: 'No se puede acceder a GitHub', + installGithubTimeoutTitle: 'Se agotó el tiempo de espera de la conexión GitHub', + installGithubFailedDescription: 'Pruebe con otra fuente de instalación.', + installUseGithubMirror: 'Utilice el espejo de China continental', + installTryAnotherWay: 'Prueba de otra manera', + installPackageLabel: 'Nombre del paquete del complemento', + installEdit: 'Editar', + installEditAria: 'Volver a editar', + installCancelAndEdit: 'Cancelar la instalación y volver a editar', + installApplyingCancellationError: 'La cancelación no fue confirmada. Se está aplicando la instalación; espera su resultado. {reason}', + installReconcile: 'Verificar el estado de la instalación', + installUnknownTitle: 'El resultado de la instalación no está disponible', + installUnknownDescription: 'El Host no tiene ninguna instalación activa con esta identificación de solicitud. Consulte la lista de complementos antes de volver a intentarlo.', + installResultUnconfirmed: 'No se recibió el resultado de la instalación. Verificar el estado de la instalación. {reason}', + installAwaitingAcceptance: 'Esperando que el Host acepte la instalación. La cancelación se volverá a intentar automáticamente después de la confirmación.', + installBackgroundUnknown: 'El resultado de la instalación no está disponible. Consulte la lista de complementos.', + installCancel: 'Cancelar instalación', + installCloseCancels: 'Cancelar instalación y cerrar', + installViewTask: 'Ver instalación', + installUnconfirmedTitle: 'Estado de instalación no confirmado', + installBackgroundDone: 'Instalación finalizada. Ver detalles de instalación.', + installBackgroundFailed: 'La instalación falló. Ver detalles de instalación.', + installBackgroundUnconfirmed: 'El estado de la instalación no está confirmado. Vea la instalación para más detalles.', + installBackgroundApplying: 'La instalación se está aplicando y no se puede cancelar. Ver el progreso de la instalación.', + installStarting: 'Preparando la instalación…', + installCancelling: 'Deteniendo la instalación…', + installApplying: 'Aplicando configuración, espere...', + installCancelledShort: 'Cancelado', + installCancelled: 'Instalación cancelada; el complemento no está habilitado y los archivos descargados pueden permanecer', + installCancelUnconfirmed: 'No se ha confirmado que la instalación se haya detenido. Vuelva a intentar la cancelación o espere el resultado de la instalación. {reason}', + installEnableNow: 'Habilitar ahora', + installDetailsShow: 'Mostrar detalles de instalación', + installDetailsHide: 'Ocultar detalles de instalación', + installVersion: 'Versión {version}', + installSubjectPath: 'directorio local', + installSubjectGit: 'repositorio git', + installSubjectTarball: 'bola de tar', + installLocation: 'Se instala en {dir}', + installRetry: 'Reintentar', + installChangeRegistry: 'Cambiar registro', + installAttempt: '{previous} no pudo entregar el paquete; reintentando a través de {registry} (registro {index} de {total})', + installAttemptBadge: 'Intento {index} · {registry}', + installFailureNetwork: 'La conexión de red falló', + installFailureNetworkAll: 'No se pudo acceder a ningún registro (probado: {registries}). Verifique la configuración de red o proxy, o cambie el registro y vuelva a intentarlo.', + installFailureNetworkHost: 'No se pudo contactar con {host}. Una dirección GitHub o un enlace .tgz no se recupera a través del registro: esta máquina debe llegar directamente o mediante un proxy. Si el complemento también está publicado en npm, ingrese el nombre del paquete.', + installFailureNotFound: 'No se encontró tal complemento', + installFailureNoMatchingVersion: 'Ninguna versión coincide con la solicitud', + installFailureDiskFull: 'El disco está lleno; la instalación se detuvo', + installFailurePermission: 'Sin permiso de escritura; el complemento no se puede instalar', + installFailureBuildBlocked: 'Los scripts de instalación de una dependencia necesitan su permiso antes de que la instalación pueda continuar', + installFailureBuildBlockedManual: 'pnpm bloqueó los scripts de instalación; permítalos en enableBuilds en pnpm-workspace.yaml y vuelva a intentarlo', + installFailureIntegrity: 'El paquete descargado no pasó la verificación de integridad', + installFailureTimeout: 'La instalación expiró', + installFailurePnpmMissing: 'No se encontró pnpm, por lo que no se puede instalar nada.', + installFailureGeneric: 'Algo salió mal durante la instalación; los detalles dicen lo que', + terminalCommandLine: 'Línea de comando {n}', + terminalRunning: 'corriendo', + terminalFailed: 'Fallido', + terminalDone: 'hecho', + terminalCopy: 'Copiar', + terminalCopied: 'Copiado', + terminalNoOutput: 'Sin salida', + terminalCollapseAria: 'Contraer salida', + terminalCollapse: 'Contraer', + terminalExpandAria: 'Expanda las líneas de salida {n} restantes', + terminalExpand: '… {n} más líneas', + terminalExitCode: 'código de salida {code}', + terminalSignal: 'señal {signal}', + terminalNoExitCode: 'sin código de salida', + installDoneNothing: 'Instalación finalizada sin nuevas dependencias.', + installDoneRestart: 'Instalado; reinicie DSH para cargarlo. {reason}', + installDoneApproved: 'Instalar scripts permitidos para {names}', + installDoneOtherVersion: 'Según la política de seguridad de la cadena de suministro de pnpm (minimumReleaseAge), las versiones publicadas más recientemente que la antigüedad requerida no se instalan automáticamente, por lo que esta instalación utilizó {installed} en lugar de la última versión {version}. Una versión recién publicada no ha completado este período de espera y puede contener códigos maliciosos o defectos no detectados. Si necesita {version}, desinstale la versión actual y luego instale {exact} para solicitar esa versión.', + installApprovalTitle: 'Instalar scripts necesita permiso', + installApprovalDescription: 'Estos paquetes tienen scripts de instalación que pnpm no ejecutó.', + installApprovalConsequence: 'Una vez permitidos, los scripts se ejecutan aquí con sus permisos y el permiso se guarda en este perfil.', + installApprovalCaution: 'Permita solo paquetes en los que confíe.', + installApproveAndRetry: 'Permita estos scripts y vuelva a intentarlo', + installClose: 'hecho', + close: 'Cerrar', + cancel: 'Cancelar', + confirmUninstallTitle: '¿Desinstalar "{name}"?', + confirmUninstallDescription: 'Lo que proporciona desaparece una vez que se desinstala.', + confirmUninstall: 'Desinstalar', + failedEnable: 'No se pudo habilitar: {reason}', + failedDisable: 'No se pudo desactivar: {reason}', + failedUninstall: 'No se pudo desinstalar: {reason}', + failedRowEnable: 'No se pudo habilitar el componente: {reason}', + failedRowDisable: 'No se pudo desactivar el componente: {reason}', + reasonManagementRequired: 'La gestión de complementos lo necesita; no se puede apagar ni desinstalar.', + reasonUnaddressable: 'El parche de perfil no puede abordar este de forma única.', + reasonUnknownPlugin: 'No existe tal complemento.', + reasonInvalidSpec: 'Ingrese un nombre o dirección de paquete válido.', + reasonAlreadyInstalled: 'Este paquete ya está instalado; desinstálelo antes de instalar otra versión.', + reasonAmbiguousInstall: 'No se puede saber qué paquete se instaló a partir del cambio de dependencia.', + reasonNotBundle: 'Este paquete no declara ningún paquete, por lo que no se puede administrar como un complemento.', + reasonNotRemovable: 'Este paquete no es propiedad del perfil o la administración del complemento lo necesita.', + reasonStopProfile: 'Este perfil se ejecuta sin HMR; deténgalo y desinstale el paquete con el complemento dsh.', + reasonBundleInUse: 'Otra configuración todavía usa los componentes de este paquete; apáguelos primero.', + reasonStaleApproval: 'Las aprobaciones de guiones pendientes cambiaron; instálelo nuevamente para actualizarlos.', + reasonIncompatibleVersion: '{plugin} es incompatible con DSH {runtime} (requiere {requirements}); ejecutarlo puede causar fallas o pérdida de datos.', + reasonIncompatibleVersionUnnamed: 'Este complemento es incompatible con la versión DSH en ejecución; ejecutarlo puede causar fallas o pérdida de datos.', + reasonIncompatibleInstall: 'Instale una versión del complemento compatible con este DSH.', + reasonIncompatibleInstalled: 'Desinstálalo e instala una versión compatible con este DSH.', + reasonOperationError: 'El anfitrión informó un error.', + detailTitle: 'Detalles', + detailLoading: 'Detalles de lectura', + detailTooLarge: 'Los detalles exceden el límite de tamaño y no se cargaron.', + detailNetwork: 'No se pudo llegar a la fuente de los detalles.', + detailInvalid: 'El complemento declara un archivo de detalles no válido.', + detailFailed: 'No se pudieron leer los detalles.', + detailFootnotes: 'Notas a pie de página', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + reasonUnsupportedPlatform: 'Ce plugin ne prend pas en charge le système d’exploitation sur lequel DSH s’exécute.', + panel: 'Plugins', + title: 'Plugins', + intro: 'Installer, activer et configurer les plugins', + infoLabel: 'À propos des plugins', + infoDescription: "Configurez les plugins officiels et installez ou gérez d'autres plugins ici. Affichez la liste des plugins intégrés et l'état d'exécution dans Paramètres → Plugins intégrés.", + loading: 'Plugins de lecture…', + error: "Impossible de lire tous les plugins, probablement à cause d'un problème de réseau", + unavailable: "Ce déploiement s'exécute sans profil gérable, les plugins ne peuvent donc pas être installés ou commutés ici.", + retry: 'Réessayer', + refresh: 'Actualiser', + refreshError: "L'actualisation a échoué. Veuillez réessayer.", + empty: "Aucun plugin n'est encore installé.", + addPlugin: 'Ajouter un plugin', + chooseAddMethod: 'Choisissez comment ajouter un plugin', + installExisting: 'Installer un plugin tiers', + installExistingDescription: 'Prend en charge les packages npm, les référentiels Git et les répertoires locaux', + restartNotice: 'Redémarrez DSH pour appliquer la modification. {reason}', + restartReasonNoHmr: 'Ce profil applique les modifications uniquement au démarrage.', + restartReasonPackageMoved: "Un package installé est désormais résolu dans un autre répertoire ou la modification remplace un package fourni par l'installation de DSH. Désinstallez un package avant de l’installer à partir d’un autre emplacement.", + restartReasonHostModule: 'Le changement atteint les modules DSH lui-même chargés.', + restartReasonUnmanagedPlugin: 'La modification atteint un plugin exécuté en dehors de la liste des plugins.', + restartReasonModuleComparison: "DSH n'a pas pu comparer les plugins chargés avec leurs fichiers.", + overriddenNotice: "{name} a été enregistré, mais une configuration de priorité plus élevée l'écrase, elle n'est donc pas effective", + bundlesTitle: 'Plugins installés', + basicTitle: 'Paramètres du plugin intégré', + extensionsTitle: 'Plugins expérimentaux', + moreTitle: 'Plugins expérimentaux', + moreAction: 'Plus', + tabsLabel: 'Vues des plugins', + installedTab: 'Installé', + statusProblem: 'Problème', + statusBeta: 'Expérimental', + reasonLabel: 'Raison', + metadataError: 'Erreur de métadonnées du package : {error}', + versionTag: 'v{version}', + partsLabel: 'Composants', + sourceTitle: 'Source', + sourceSpec: 'Source du code', + sourceBuiltIn: 'Intégré', + sourceVersion: 'Version actuelle', + partsEmpty: 'Ce pack de plugins ne contient aucun composant.', + partsCountTotal: 'Total {count}', + partsCountRunning: "{count} en cours d'exécution", + partsCountOff: '{count} désactivé', + partOff: 'Désactivé', + partsCountFailed: 'Échec du {count}', + partsFilter: 'Composants du filtre', + partsFilterEmpty: 'Aucun composant ne correspond.', + partToggle: 'Activer le composant {name}', + rowPhasePending: 'En attente de dépendances', + rowPhaseLoading: 'Chargement', + rowPhaseActive: 'Courir', + rowPhaseFailed: 'Problème', + rowPhaseUnloading: 'Déchargement', + enableToggle: 'Activer {name}', + applyingChange: 'Application des modifications à {name}', + openDetail: 'Voir {name}', + backToList: 'Retour aux plugins', + crumbRoot: 'Plugins', + backToPackage: 'Retour à {name}', + configureRow: 'Configurer {name}', + rowStateIdle: 'Ne fonctionne pas', + uninstall: 'Désinstaller', + uninstallLabel: 'Désinstaller {name}', + installTitle: 'Ajouter un plugin', + installDescription: "Entrez le nom du package du plugin, l'adresse du référentiel GitHub ou le chemin du répertoire local.", + installSpecLabel: 'Nom ou adresse du colis', + installSpecPlaceholder: 'par exemple dsh-plugin-whale-pet', + installGuideToggle: "Guide d'installation et exemples", + installGuideHide: 'Masquer le guide', + installGuideIdTitle: 'Entrez le nom du package npm du plugin', + installGuideIdExample: 'dsh-plugin-baleine-animal de compagnie', + installGuideIdHint: "Le nom du package du plugin est le nom du package npm (comme dsh-xxx ou @author/plugin) : la partie après dsh plugin add ou pnpm add dans la commande d'installation README d'un plugin communautaire.", + installGuideExampleLabel: 'Exemple : ', + installGitTemplateHint: "Remplacez-le par l'adresse réelle du référentiel Git.", + installPathTemplateHint: 'Remplacez-le par le chemin réel vers votre répertoire de plugin local.', + installGuideFill: "Utiliser l'exemple", + installGuideFillAria: "Utilisez l'exemple {example}", + installGuideSafety: "Installez uniquement les plugins en qui vous avez confiance : ils s'exécutent avec vos autorisations et peuvent endommager DeepSeek Harness ou divulguer vos données.", + installUpgradeNotice: "Les plugins officiels à la demande proposent la mise à jour lorsque leur version diffère de DSH. Pour mettre à jour d'autres plugins, désinstallez-les et réinstallez-les.", + registryToggle: 'Registre', + registryLegend: 'Le registre npm à partir duquel le plugin est téléchargé', + registryDefault: 'Registre par défaut', + registryOfficial: 'Registre officiel npm', + registryNpmmirror: 'Miroir de Chine continentale', + registryCustom: 'Adresse personnalisée', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: "Entrez une adresse de registre npm interne ou privée commençant par http:// ou https://.. Si cela nécessite une connexion, stockez les informations d'identification dans ~/.npmrc sur cette machine.", + registryCustomInvalid: 'Saisissez une adresse commençant par http:// ou https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Installer', + installChecking: 'Vérification…', + installConfirmedTitle: 'Installer le plug-in', + installConfirmedRefused: 'Ce plugin ne peut pas être installé', + installProblemInvalid: "Il ne s'agit pas d'un nom ou d'une adresse de package pouvant être installé : {reason}", + installProblemInstalled: 'Ce plugin est déjà installé. Pour le mettre à niveau, désinstallez-le et réinstallez-le', + installProblemShipped: 'Ce plugin est livré avec DSH ; la mise à niveau de DSH le met à jour', + installBundledTitle: 'Ce plugin est livré avec DSH', + installBundledDescription: 'Activez-le sans télécharger une autre copie', + installProblemNotFound: "Aucun plugin de ce type n'a été trouvé", + installProblemNotPackage: "Le chemin n'existe pas ou n'est pas un package de plugin valide", + installProblemNotBundle: 'Ce package ne déclare aucun bundle, il ne peut donc pas être installé en tant que plugin : {reason}', + installProblemNetwork: "Le registre du plugin n'a pas pu être atteint ; vérifie le réseau et réessaye", + installProblemNetworkAll: "Aucun registre n'a pu être atteint (essayé : {registries}) ; vérifiez les paramètres réseau ou proxy, ou modifiez le registre", + installProblemUnknown: "Le plugin n'a pas pu être recherché : {reason}", + installingTitle: 'Installation du plugin…', + installedTitle: 'Installé', + statusNotInstalled: 'Non installé', + statusMissingFiles: 'Forfait indisponible', + statusUpdateAvailable: 'Mise à jour disponible', + sourceNotInstalled: 'Non installé', + sourceTargetSpec: 'Source pour ce DSH', + sourceTargetVersion: 'Version pour ce DSH', + update: 'Mise à jour', + updateLabel: 'Mettre à jour {name}', + versionsTitle: 'Versions', + versionsRecheck: 'Vérifiez à nouveau', + versionsChecking: 'Vérification des mises à jour', + versionsAvailable: 'Mise à jour vers {version} disponible', + versionsPublished: 'Publié {date}', + versionsLatest: 'À jour', + versionsCheckedAt: 'Dernière vérification à {time}', + versionsBlocked: 'Les versions plus récentes nécessitent une mise à jour DSH', + versionsBlockedDetail: '{version} a besoin de {peers} ; ce DSH est {runtime}', + versionsNoneInstallable: 'Aucune version plus récente à installer directement', + versionsNoneInstallableDetail: 'Les versions les plus récentes sont obsolètes, sont des versions préliminaires ou ne déclarent aucun bundle. Agrandissez pour les voir', + versionsFailedNetwork: "Impossible d'accéder au registre", + versionsFailedOther: 'Impossible de vérifier les mises à jour', + versionsDetails: 'Détails', + versionsNewerCount: '{count} plus récent', + versionsCollapse: 'Réduire', + versionsInstalledTag: 'Installé', + versionsPrereleaseTag: 'Avant-première', + versionsDeprecatedTag: 'Obsolète', + versionsNeedsDsh: 'Besoins {peers}', + versionsNotBundle: 'Cette version ne déclare aucun bundle et ne peut pas être installée', + versionsUpdateLabel: 'Mise à jour vers {version}', + versionsRegistry: 'Registre', + versionsCheckedAtLabel: 'Vérifié à', + versionsFallbackTag: 'Repli', + versionsFallbackHint: "{registry} n'a pas pu être atteint ; la liste provient d'un registre de secours", + versionsPrimaryTag: "D'abord", + versionsShowPrerelease: 'Afficher les avant-premières', + versionsOmitted: '{count} et les versions plus récentes que celle installée ne sont pas répertoriées', + versionsProblemNetwork: 'Injoignable', + versionsProblemNotFound: 'Pas trouvé', + versionsProblemUnknown: 'Échec', + updatesAvailable: 'Mises à jour disponibles : {count}', + updatesNone: 'Tous les plugins sont à jour', + updatesFailed: "Certains plugins n'ont pas pu être vérifiés", + updateTitle: 'Mettre à jour {name}', + updateSource: 'Installer à partir de', + updateDescription: "La mise à jour remplace {from} par {to} ; le plugin peut s'arrêter brièvement. Si l'installation échoue, {from} est conservé ou réinstallé.", + updateRunning: 'Mise à jour', + updateStepChecking: 'Vérifiez {version}', + updateStepRemoving: 'Arrêtez et supprimez {version}', + updateStepInstalling: 'Installez {version} à partir de {registry}', + updateStepRestoring: 'Installez à nouveau {version}', + updateDone: 'Mis à jour vers {version}', + updateDoneDetail: "La nouvelle version est en cours d'exécution.", + updateRestart: '{version} installé ; cela prend effet après un redémarrage', + updateRefused: 'Impossible de mettre à jour vers {version}', + updateRemoveFailed: 'Impossible de supprimer {version}', + updateRestored: 'La mise à jour a échoué ; restauré {version}', + updateRestoredDetail: "Échec de l'installation de {to} ; {from} a été réinstallé.", + updateKept: 'Impossible de mettre à jour vers {version}', + updateKeptDetail: '{from} n’a pas été supprimé et reste utilisé. {reason}', + updateBroken: "La mise à jour a échoué ; le plugin n'est pas installé", + updateBrokenDetail: "L'installation de {to} a échoué et l'installation de {from} a également échoué. Installez à nouveau {name} à partir d'Ajouter un plugin.", + updateLost: 'Le résultat de la mise à jour est inconnu', + updateLostDetail: 'La connexion à DSH a été interrompue : {reason}. Actualisez la liste des plugins pour voir la version installée.', + updateOutput: "Afficher le résultat de l'installation", + updateFinish: 'Terminé', + installFailedTitle: "Le plugin n'a pas pu être installé", + installGithubFailedTitle: "Impossible d'accéder à GitHub", + installGithubTimeoutTitle: 'La connexion GitHub a expiré', + installGithubFailedDescription: "Essayez une autre source d'installation.", + installUseGithubMirror: 'Utiliser un miroir de Chine continentale', + installTryAnotherWay: 'Essayez une autre façon', + installPackageLabel: 'Nom du package du plugin', + installEdit: 'Modifier', + installEditAria: "Retour à l'édition", + installCancelAndEdit: "Annuler l'installation et revenir à l'édition", + installApplyingCancellationError: "L'annulation n'a pas été confirmée. L'installation est en cours d'application ; attendre son résultat. {reason}", + installReconcile: "Vérifier l'état de l'installation", + installUnknownTitle: "Résultat de l'installation indisponible", + installUnknownDescription: "L'hôte n'a aucune installation active avec cet identifiant de demande. Vérifiez la liste des plugins avant de réessayer.", + installResultUnconfirmed: "Le résultat de l'installation n'a pas été reçu. Vérifiez l'état de l'installation. {reason}", + installAwaitingAcceptance: "En attente que l'hôte accepte l'installation. L'annulation sera réessayée automatiquement après confirmation.", + installBackgroundUnknown: "Résultat de l'installation indisponible. Vérifiez la liste des plugins.", + installCancel: "Annuler l'installation", + installCloseCancels: "Annuler l'installation et fermer", + installViewTask: "Voir l'installation", + installUnconfirmedTitle: "État de l'installation non confirmé", + installBackgroundDone: "Installation terminée. Afficher les détails de l'installation.", + installBackgroundFailed: "L'installation a échoué. Afficher les détails de l'installation.", + installBackgroundUnconfirmed: "L'état de l'installation n'est pas confirmé. Consultez l'installation pour plus de détails.", + installBackgroundApplying: "L'installation est en cours d'application et ne peut pas être annulée. Afficher la progression de l'installation.", + installStarting: "Préparation de l'installation…", + installCancelling: "Arrêt de l'installation…", + installApplying: 'Application de la configuration, veuillez patienter…', + installCancelledShort: 'Annulé', + installCancelled: "Installation annulée ; le plugin n'est pas activé et les fichiers téléchargés peuvent rester", + installCancelUnconfirmed: "L'installation n'a pas été confirmée arrêtée. Réessayez l'annulation ou attendez le résultat de l'installation. {reason}", + installEnableNow: 'Activer maintenant', + installDetailsShow: "Afficher les détails de l'installation", + installDetailsHide: "Masquer les détails de l'installation", + installVersion: 'Version {version}', + installSubjectPath: 'Annuaire local', + installSubjectGit: 'Dépôt Git', + installSubjectTarball: 'Tarball', + installLocation: "S'installe dans {dir}", + installRetry: 'Réessayer', + installChangeRegistry: 'Changer de registre', + installAttempt: "{previous} n'a pas pu servir le package ; nouvelle tentative via {registry} (registre {index} de {total})", + installAttemptBadge: 'Tentative {index} · {registry}', + installFailureNetwork: 'La connexion réseau a échoué', + installFailureNetworkAll: "Aucun registre n'a pu être atteint (essayé : {registries}). Vérifiez les paramètres réseau ou proxy, ou modifiez le registre et réessayez.", + installFailureNetworkHost: "{host} n'a pas pu être atteint. Une adresse GitHub ou un lien .tgz ne sont pas récupérés via le registre : cette machine doit y accéder directement ou via un proxy. Si le plugin est également publié sur npm, saisissez plutôt le nom de son package.", + installFailureNotFound: "Aucun plugin de ce type n'a été trouvé", + installFailureNoMatchingVersion: 'Aucune version ne correspond à la demande', + installFailureDiskFull: "Le disque est plein ; l'installation s'est arrêtée", + installFailurePermission: "Aucune autorisation d'écriture ; le plugin ne peut pas être installé", + installFailureBuildBlocked: "Les scripts d'installation d'une dépendance ont besoin de votre autorisation avant que l'installation puisse continuer", + installFailureBuildBlockedManual: "pnpm a bloqué les scripts d'installation ; autorisez-les sous AllowBuilds dans pnpm-workspace.yaml et réessayez", + installFailureIntegrity: "Le package téléchargé a échoué à sa vérification d'intégrité", + installFailureTimeout: "L'installation a expiré", + installFailurePnpmMissing: "pnpm n'a pas été trouvé, donc rien ne peut être installé", + installFailureGeneric: "Quelque chose s'est mal passé lors de l'installation ; les détails disent quoi", + terminalCommandLine: 'Ligne de commande {n}', + terminalRunning: 'Courir', + terminalFailed: 'Échec', + terminalDone: 'Terminé', + terminalCopy: 'Copier', + terminalCopied: 'Copié', + terminalNoOutput: 'Aucune sortie', + terminalCollapseAria: 'Réduire la sortie', + terminalCollapse: 'Réduire', + terminalExpandAria: 'Développez les lignes de sortie {n} restantes', + terminalExpand: '… {n} plus de lignes', + terminalExitCode: 'code de sortie {code}', + terminalSignal: 'signal {signal}', + terminalNoExitCode: 'pas de code de sortie', + installDoneNothing: 'Installation terminée sans nouvelle dépendance.', + installDoneRestart: 'Installé ; redémarrez DSH pour le charger. {reason}', + installDoneApproved: "Scripts d'installation autorisés pour {names}", + installDoneOtherVersion: "Conformément à la politique de sécurité de la chaîne d'approvisionnement de pnpm (minimumReleaseAge), les versions publiées plus récemment que l'âge requis ne sont pas installées automatiquement. Cette installation a donc utilisé {installed} au lieu de la dernière version {version}. Une version nouvellement publiée n'a pas dépassé cette période d'attente et peut contenir du code malveillant ou des défauts non détectés. Si vous avez besoin de {version}, désinstallez la version actuelle, puis installez {exact} pour demander cette version.", + installApprovalTitle: "Les scripts d'installation nécessitent une autorisation", + installApprovalDescription: "Ces packages contiennent des scripts d'installation que pnpm n'a pas exécutés.", + installApprovalConsequence: "Une fois autorisés, les scripts s'exécutent ici avec vos autorisations et l'autorisation est enregistrée dans ce profil.", + installApprovalCaution: 'Autorisez uniquement les packages auxquels vous faites confiance.', + installApproveAndRetry: 'Autoriser ces scripts et réessayer', + installClose: 'Terminé', + close: 'Fermer', + cancel: 'Annuler', + confirmUninstallTitle: 'Désinstaller "{name}" ?', + confirmUninstallDescription: "Ce qu'il fournit disparaît une fois désinstallé.", + confirmUninstall: 'Désinstaller', + failedEnable: "Impossible d'activer : {reason}", + failedDisable: 'Impossible de désactiver : {reason}', + failedUninstall: 'Impossible de désinstaller : {reason}', + failedRowEnable: "Impossible d'activer le composant : {reason}", + failedRowDisable: 'Impossible de désactiver le composant : {reason}', + reasonManagementRequired: 'La gestion des plugins en a besoin ; il ne peut pas être éteint ou désinstallé.', + reasonUnaddressable: 'Le correctif de profil ne peut pas résoudre ce problème de manière unique.', + reasonUnknownPlugin: 'Aucun plugin de ce type.', + reasonInvalidSpec: 'Entrez un nom ou une adresse de package valide.', + reasonAlreadyInstalled: "Ce package est déjà installé ; désinstallez-le avant d'installer une autre version.", + reasonAmbiguousInstall: 'Le changement de dépendance ne permet pas de déterminer quel package a été installé.', + reasonNotBundle: 'Ce package ne déclare aucun bundle, il ne peut donc pas être géré comme un plugin.', + reasonNotRemovable: "Ce package n'appartient pas au profil, ou la gestion des plugins en a besoin.", + reasonStopProfile: "Ce profil s'exécute sans HMR ; arrêtez-le et désinstallez le package avec le plugin dsh.", + reasonBundleInUse: "Une autre configuration utilise toujours les composants de ce bundle ; éteignez-les d'abord.", + reasonStaleApproval: 'Les approbations de script en attente ont changé ; réinstallez-les pour les actualiser.', + reasonIncompatibleVersion: '{plugin} est incompatible avec DSH {runtime} (nécessite {requirements}) ; son exécution peut provoquer des plantages ou une perte de données.', + reasonIncompatibleVersionUnnamed: "Ce plugin est incompatible avec la version DSH en cours d'exécution ; son exécution peut provoquer des plantages ou une perte de données.", + reasonIncompatibleInstall: 'Installez une version du plugin compatible avec ce DSH.', + reasonIncompatibleInstalled: 'Désinstallez-le et installez une version compatible avec ce DSH.', + reasonOperationError: "L'hôte a signalé une erreur.", + detailTitle: 'Détails', + detailLoading: 'Détails de lecture', + detailTooLarge: "Les détails dépassent la limite de taille et n'ont pas été chargés.", + detailNetwork: "Impossible d'atteindre la source des détails.", + detailInvalid: 'Le plugin déclare un fichier de détails invalide.', + detailFailed: 'Impossible de lire les détails.', + detailFootnotes: 'Notes de bas de page', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + reasonUnsupportedPlatform: 'Dieses Plugin unterstützt das Betriebssystem, auf dem DSH läuft, nicht.', + panel: 'Plugins', + title: 'Plugins', + intro: 'Plugins installieren, aktivieren und konfigurieren', + infoLabel: 'Über Plugins', + infoDescription: 'Konfigurieren Sie hier offizielle Plugins und installieren oder verwalten Sie andere Plugins. Sehen Sie sich die Liste der integrierten Plugins und den Laufzeitstatus unter Einstellungen → Integrierte Plugins an.', + loading: 'Plugins lesen…', + error: 'Es konnten nicht alle Plugins gelesen werden, möglicherweise aufgrund eines Netzwerkproblems', + unavailable: 'Diese Bereitstellung läuft ohne verwaltbares Profil, daher können hier keine Plugins installiert oder gewechselt werden.', + retry: 'Erneut versuchen', + refresh: 'Aktualisieren', + refreshError: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.', + empty: 'Es sind noch keine Plugins installiert.', + addPlugin: 'Plugin hinzufügen', + chooseAddMethod: 'Wählen Sie aus, wie Sie ein Plugin hinzufügen möchten', + installExisting: 'Installieren Sie ein Drittanbieter-Plugin', + installExistingDescription: 'Unterstützt npm-Pakete, Git-Repositorys und lokale Verzeichnisse', + restartNotice: 'Starten Sie DSH neu, um die Änderung zu übernehmen. {reason}', + restartReasonNoHmr: 'Dieses Profil wendet Änderungen nur beim Start an.', + restartReasonPackageMoved: 'Ein installiertes Paket wird nun in ein anderes Verzeichnis aufgelöst, oder die Änderung ersetzt ein Paket, das die DSH-Installation bereitstellt. Deinstallieren Sie ein Paket, bevor Sie es von einem anderen Speicherort installieren.', + restartReasonHostModule: 'Die Änderung erreicht die geladenen Module DSH selbst.', + restartReasonUnmanagedPlugin: 'Die Änderung betrifft ein Plugin, das außerhalb der Plugin-Liste ausgeführt wird.', + restartReasonModuleComparison: 'DSH konnte die geladenen Plugins nicht mit ihren Dateien vergleichen.', + overriddenNotice: '{name} wurde gespeichert, aber eine Konfiguration mit höherer Priorität überschreibt es, sodass es nicht wirksam ist', + bundlesTitle: 'Installierte Plugins', + basicTitle: 'Integrierte Plugin-Einstellungen', + extensionsTitle: 'Experimentelle Plugins', + moreTitle: 'Experimentelle Plugins', + moreAction: 'Mehr', + tabsLabel: 'Plugin-Ansichten', + installedTab: 'Installiert', + statusProblem: 'Problem', + statusBeta: 'Experimentell', + reasonLabel: 'Grund', + metadataError: 'Paketmetadatenfehler: {error}', + versionTag: 'v{version}', + partsLabel: 'Komponenten', + sourceTitle: 'Quelle', + sourceSpec: 'Codequelle', + sourceBuiltIn: 'Eingebaut', + sourceVersion: 'Aktuelle Version', + partsEmpty: 'Dieses Plugin-Paket enthält keine Komponenten.', + partsCountTotal: '{count} insgesamt', + partsCountRunning: '{count} läuft', + partsCountOff: '{count} aus', + partOff: 'Aus', + partsCountFailed: '{count} ist fehlgeschlagen', + partsFilter: 'Komponenten filtern', + partsFilterEmpty: 'Keine Komponentenübereinstimmungen.', + partToggle: 'Aktivieren Sie die Komponente {name}', + rowPhasePending: 'Warten auf Abhängigkeiten', + rowPhaseLoading: 'Laden', + rowPhaseActive: 'Laufen', + rowPhaseFailed: 'Problem', + rowPhaseUnloading: 'Entladen', + enableToggle: 'Aktivieren Sie {name}', + applyingChange: 'Anwenden von Änderungen auf {name}', + openDetail: 'Siehe {name}', + backToList: 'Zurück zu Plugins', + crumbRoot: 'Plugins', + backToPackage: 'Zurück zu {name}', + configureRow: 'Konfigurieren Sie {name}', + rowStateIdle: 'Läuft nicht', + uninstall: 'Deinstallieren', + uninstallLabel: 'Deinstallieren Sie {name}', + installTitle: 'Plugin hinzufügen', + installDescription: 'Geben Sie den Paketnamen des Plugins, die GitHub-Repository-Adresse oder den lokalen Verzeichnispfad ein.', + installSpecLabel: 'Name oder Adresse des Pakets', + installSpecPlaceholder: 'zum Beispiel dsh-plugin-whale-pet', + installGuideToggle: 'Installationsanleitung und Beispiele', + installGuideHide: 'Verstecke den Leitfaden', + installGuideIdTitle: 'Geben Sie den NPM-Paketnamen des Plugins ein', + installGuideIdExample: 'dsh-plugin-whale-pet', + installGuideIdHint: 'Der Plugin-Paketname ist der npm-Paketname (wie dsh-xxx oder @author/plugin): der Teil nach dshplugin add oder pnpm add im README-Installationsbefehl eines Community-Plugins.', + installGuideExampleLabel: 'Beispiel: ', + installGitTemplateHint: 'Ersetzen Sie dies durch die tatsächliche Git-Repository-Adresse.', + installPathTemplateHint: 'Ersetzen Sie dies durch den tatsächlichen Pfad zu Ihrem lokalen Plugin-Verzeichnis.', + installGuideFill: 'Beispiel verwenden', + installGuideFillAria: 'Verwenden Sie das Beispiel {example}', + installGuideSafety: 'Installieren Sie nur Plugins, denen Sie vertrauen: Sie werden mit Ihren Berechtigungen ausgeführt und können DeepSeek Harness beschädigen oder Ihre Daten preisgeben.', + installUpgradeNotice: 'Offizielle On-Demand-Plugins bieten Updates an, wenn ihre Version von DSH abweicht. Um andere Plugins zu aktualisieren, deinstallieren Sie sie und installieren Sie sie erneut.', + registryToggle: 'Registrierung', + registryLegend: 'Die NPM-Registrierung, aus der das Plugin heruntergeladen wird', + registryDefault: 'Standardregistrierung', + registryOfficial: 'Offizielle npm-Registrierung', + registryNpmmirror: 'Spiegel vom chinesischen Festland', + registryCustom: 'Benutzerdefinierte Adresse', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Geben Sie eine interne oder private NPM-Registrierungsadresse ein, beginnend mit http:// oder https://.. Wenn eine Anmeldung erforderlich ist, speichern Sie die Anmeldeinformationen in ~/.npmrc auf diesem Computer.', + registryCustomInvalid: 'Geben Sie eine Adresse ein, die mit http:// oder https:// beginnt.', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Installieren', + installChecking: 'Überprüfen…', + installConfirmedTitle: 'Plugin installieren', + installConfirmedRefused: 'Dieses Plugin kann nicht installiert werden', + installProblemInvalid: 'Dies ist kein Paketname oder keine Adresse, die installiert werden kann: {reason}', + installProblemInstalled: 'Dieses Plugin ist bereits installiert. Um es zu aktualisieren, deinstallieren Sie es und installieren Sie es erneut', + installProblemShipped: 'Dieses Plugin wird mit DSH geliefert; Durch das Aktualisieren von DSH wird es aktualisiert', + installBundledTitle: 'Dieses Plugin wird mit DSH geliefert', + installBundledDescription: 'Aktivieren Sie es, ohne eine weitere Kopie herunterzuladen', + installProblemNotFound: 'Es wurde kein solches Plugin gefunden', + installProblemNotPackage: 'Der Pfad existiert nicht oder ist kein gültiges Plugin-Paket', + installProblemNotBundle: 'Dieses Paket deklariert kein Bundle und kann daher nicht als Plugin installiert werden: {reason}', + installProblemNetwork: 'Die Plugin-Registrierung konnte nicht erreicht werden; Überprüfen Sie das Netzwerk und versuchen Sie es erneut', + installProblemNetworkAll: 'Es konnte keine Registry erreicht werden (versucht: {registries}); Überprüfen Sie die Netzwerk- oder Proxy-Einstellungen oder ändern Sie die Registrierung', + installProblemUnknown: 'Das Plugin konnte nicht gefunden werden: {reason}', + installingTitle: 'Plugin installieren…', + installedTitle: 'Installiert', + statusNotInstalled: 'Nicht installiert', + statusMissingFiles: 'Paket nicht verfügbar', + statusUpdateAvailable: 'Update verfügbar', + sourceNotInstalled: 'Nicht installiert', + sourceTargetSpec: 'Quelle hierfür DSH', + sourceTargetVersion: 'Version für dieses DSH', + update: 'Aktualisieren', + updateLabel: 'Aktualisieren Sie {name}', + versionsTitle: 'Versionen', + versionsRecheck: 'Überprüfen Sie es noch einmal', + versionsChecking: 'Suche nach Updates', + versionsAvailable: 'Update auf {version} verfügbar', + versionsPublished: 'Veröffentlicht {date}', + versionsLatest: 'Auf dem neuesten Stand', + versionsCheckedAt: 'Zuletzt überprüft bei {time}', + versionsBlocked: 'Neuere Versionen benötigen ein DSH-Update', + versionsBlockedDetail: '{version} benötigt {peers}; dieser DSH ist {runtime}', + versionsNoneInstallable: 'Keine neuere Version zur direkten Installation', + versionsNoneInstallableDetail: 'Die neueren Versionen sind veraltet, Vorabversionen oder deklarieren kein Bundle. Erweitern Sie, um sie zu sehen', + versionsFailedNetwork: 'Die Registrierung konnte nicht erreicht werden', + versionsFailedOther: 'Es konnte nicht nach Updates gesucht werden', + versionsDetails: 'Details', + versionsNewerCount: '{count} neuer', + versionsCollapse: 'Einklappen', + versionsInstalledTag: 'Installiert', + versionsPrereleaseTag: 'Vorabversion', + versionsDeprecatedTag: 'Veraltet', + versionsNeedsDsh: 'Benötigt {peers}', + versionsNotBundle: 'Diese Version deklariert kein Bundle und kann nicht installiert werden', + versionsUpdateLabel: 'Update auf {version}', + versionsRegistry: 'Registrierung', + versionsCheckedAtLabel: 'Geprüft bei', + versionsFallbackTag: 'Rückfall', + versionsFallbackHint: '{registry} konnte nicht erreicht werden; Die Liste stammt aus einer Fallback-Registrierung', + versionsPrimaryTag: 'Erstens', + versionsShowPrerelease: 'Vorabversionen anzeigen', + versionsOmitted: '{count} Weitere Versionen, die neuer als die installierte sind, werden nicht aufgeführt', + versionsProblemNetwork: 'Unerreichbar', + versionsProblemNotFound: 'Nicht gefunden', + versionsProblemUnknown: 'Fehlgeschlagen', + updatesAvailable: 'Verfügbare Updates: {count}', + updatesNone: 'Alle Plugins sind auf dem neuesten Stand', + updatesFailed: 'Einige Plugins konnten nicht überprüft werden', + updateTitle: 'Aktualisieren Sie {name}', + updateSource: 'Installieren von', + updateDescription: 'Das Update ersetzt {from} durch {to}; Das Plugin stoppt möglicherweise kurzzeitig. Wenn die Installation fehlschlägt, bleibt {from} erhalten oder wird erneut installiert.', + updateRunning: 'Aktualisierung', + updateStepChecking: 'Überprüfen Sie {version}', + updateStepRemoving: 'Stoppen und entfernen Sie {version}', + updateStepInstalling: 'Installieren Sie {version} von {registry}', + updateStepRestoring: 'Installieren Sie {version} erneut', + updateDone: 'Aktualisiert auf {version}', + updateDoneDetail: 'Die neue Version läuft.', + updateRestart: '{version} installiert; es wird erst nach einem Neustart wirksam', + updateRefused: 'Aktualisierung auf {version} nicht möglich', + updateRemoveFailed: '{version} konnte nicht entfernt werden', + updateRestored: 'Aktualisierung fehlgeschlagen; wiederhergestellt {version}', + updateRestoredDetail: '{to} konnte nicht installiert werden; {from} wurde erneut installiert.', + updateKept: 'Konnte nicht auf {version} aktualisiert werden', + updateKeptDetail: '{from} wurde nicht entfernt und wird weiterhin verwendet. {reason}', + updateBroken: 'Aktualisierung fehlgeschlagen; Das Plugin ist nicht installiert', + updateBrokenDetail: 'Die Installation von {to} ist fehlgeschlagen, und auch die erneute Installation von {from} schlug fehl. Installieren Sie {name} erneut über Plugin hinzufügen.', + updateLost: 'Das Update-Ergebnis ist unbekannt', + updateLostDetail: 'Die Verbindung zu DSH wurde unterbrochen: {reason}. Aktualisieren Sie die Plugin-Liste, um die installierte Version anzuzeigen.', + updateOutput: 'Installationsausgabe anzeigen', + updateFinish: 'Fertig', + installFailedTitle: 'Das Plugin konnte nicht installiert werden', + installGithubFailedTitle: 'Auf GitHub kann nicht zugegriffen werden', + installGithubTimeoutTitle: 'Zeitüberschreitung bei der GitHub-Verbindung', + installGithubFailedDescription: 'Versuchen Sie es mit einer anderen Installationsquelle.', + installUseGithubMirror: 'Verwenden Sie einen Spiegel vom chinesischen Festland', + installTryAnotherWay: 'Versuchen Sie es anders', + installPackageLabel: 'Name des Plugin-Pakets', + installEdit: 'Bearbeiten', + installEditAria: 'Zurück zur Bearbeitung', + installCancelAndEdit: 'Brechen Sie die Installation ab und kehren Sie zur Bearbeitung zurück', + installApplyingCancellationError: 'Die Stornierung wurde nicht bestätigt. Installation wird angewendet; warte auf das Ergebnis. {reason}', + installReconcile: 'Überprüfen Sie den Installationsstatus', + installUnknownTitle: 'Installationsergebnis nicht verfügbar', + installUnknownDescription: 'Der Host verfügt über keine aktive Installation mit dieser Anforderungs-ID. Überprüfen Sie die Plugin-Liste, bevor Sie es erneut versuchen.', + installResultUnconfirmed: 'Das Installationsergebnis wurde nicht empfangen. Überprüfen Sie den Installationsstatus. {reason}', + installAwaitingAcceptance: 'Warten darauf, dass der Host die Installation akzeptiert. Die Stornierung wird nach der Bestätigung automatisch wiederholt.', + installBackgroundUnknown: 'Installationsergebnis nicht verfügbar. Überprüfen Sie die Plugin-Liste.', + installCancel: 'Installation abbrechen', + installCloseCancels: 'Installation abbrechen und schließen', + installViewTask: 'Installation ansehen', + installUnconfirmedTitle: 'Installationsstatus unbestätigt', + installBackgroundDone: 'Installation abgeschlossen. Installationsdetails anzeigen.', + installBackgroundFailed: 'Die Installation ist fehlgeschlagen. Installationsdetails anzeigen.', + installBackgroundUnconfirmed: 'Der Installationsstatus ist unbestätigt. Weitere Informationen finden Sie in der Installation.', + installBackgroundApplying: 'Die Installation wird ausgeführt und kann nicht abgebrochen werden. Installationsfortschritt anzeigen.', + installStarting: 'Installation vorbereiten…', + installCancelling: 'Installation wird gestoppt…', + installApplying: 'Konfiguration wird angewendet, bitte warten…', + installCancelledShort: 'Abgesagt', + installCancelled: 'Installation abgebrochen; Das Plugin ist nicht aktiviert und heruntergeladene Dateien bleiben möglicherweise bestehen', + installCancelUnconfirmed: 'Die Installation wurde nicht bestätigt und gestoppt. Versuchen Sie den Abbruch erneut oder warten Sie auf das Installationsergebnis. {reason}', + installEnableNow: 'Jetzt aktivieren', + installDetailsShow: 'Installationsdetails anzeigen', + installDetailsHide: 'Installationsdetails ausblenden', + installVersion: 'Version {version}', + installSubjectPath: 'Lokales Verzeichnis', + installSubjectGit: 'Git-Repository', + installSubjectTarball: 'Tarball', + installLocation: 'Wird in {dir} installiert', + installRetry: 'Erneut versuchen', + installChangeRegistry: 'Registrierung ändern', + installAttempt: '{previous} konnte das Paket nicht bereitstellen; erneuter Versuch über {registry} (Registrierung {index} von {total})', + installAttemptBadge: 'Versuchen Sie {index} · {registry}', + installFailureNetwork: 'Die Netzwerkverbindung ist fehlgeschlagen', + installFailureNetworkAll: 'Es konnte keine Registry erreicht werden (versucht: {registries}). Überprüfen Sie die Netzwerk- oder Proxy-Einstellungen oder ändern Sie die Registrierung und versuchen Sie es erneut.', + installFailureNetworkHost: '{host} konnte nicht erreicht werden. Eine GitHub-Adresse oder ein .tgz-Link wird nicht über die Registrierung abgerufen: Dieser Computer muss sie direkt oder über einen Proxy erreichen. Wenn das Plugin auch auf npm veröffentlicht wird, geben Sie stattdessen seinen Paketnamen ein.', + installFailureNotFound: 'Es wurde kein solches Plugin gefunden', + installFailureNoMatchingVersion: 'Keine Version entspricht der Anfrage', + installFailureDiskFull: 'Die Festplatte ist voll; Die Installation wurde gestoppt', + installFailurePermission: 'Keine Schreibberechtigung; Das Plugin kann nicht installiert werden', + installFailureBuildBlocked: 'Die Installationsskripte einer Abhängigkeit benötigen Ihre Erlaubnis, bevor die Installation fortgesetzt werden kann', + installFailureBuildBlockedManual: 'pnpm blockierte Installationsskripte; Erlauben Sie sie unter „allowBuilds“ in „pnpm-workspace.yaml“ und versuchen Sie es erneut', + installFailureIntegrity: 'Das heruntergeladene Paket hat die Integritätsprüfung nicht bestanden', + installFailureTimeout: 'Bei der Installation ist eine Zeitüberschreitung aufgetreten', + installFailurePnpmMissing: 'pnpm wurde nicht gefunden, daher kann nichts installiert werden', + installFailureGeneric: 'Bei der Installation ist ein Fehler aufgetreten. Die Details sagen was', + terminalCommandLine: 'Befehlszeile {n}', + terminalRunning: 'Laufen', + terminalFailed: 'Fehlgeschlagen', + terminalDone: 'Fertig', + terminalCopy: 'Kopieren', + terminalCopied: 'Kopiert', + terminalNoOutput: 'Keine Ausgabe', + terminalCollapseAria: 'Ausgabe reduzieren', + terminalCollapse: 'Einklappen', + terminalExpandAria: 'Erweitern Sie die verbleibenden {n}-Ausgabezeilen', + terminalExpand: '… {n} weitere Zeilen', + terminalExitCode: 'Exit-Code {code}', + terminalSignal: 'Signal {signal}', + terminalNoExitCode: 'kein Exit-Code', + installDoneNothing: 'Die Installation ist ohne neue Abhängigkeit abgeschlossen.', + installDoneRestart: 'Installiert; Starten Sie DSH neu, um es zu laden. {reason}', + installDoneApproved: 'Für {names} zulässige Installationsskripte', + installDoneOtherVersion: 'Gemäß der Supply-Chain-Sicherheitsrichtlinie von pnpm (minimumReleaseAge) werden Versionen, die jünger als das erforderliche Alter veröffentlicht wurden, nicht automatisch installiert. Daher wurde bei dieser Installation {installed} anstelle der neuesten Version {version} verwendet. Eine neu veröffentlichte Version hat diese Wartezeit nicht durchlaufen und kann unerkannten Schadcode oder Mängel enthalten. Wenn Sie {version} benötigen, deinstallieren Sie die aktuelle Version und installieren Sie dann {exact}, um diese Version anzufordern.', + installApprovalTitle: 'Installationsskripte benötigen eine Genehmigung', + installApprovalDescription: 'Diese Pakete verfügen über Installationsskripte, die pnpm nicht ausgeführt hat.', + installApprovalConsequence: 'Sobald sie zugelassen sind, werden die Skripte hier mit Ihren Berechtigungen ausgeführt und die Berechtigung wird in diesem Profil gespeichert.', + installApprovalCaution: 'Lassen Sie nur Pakete zu, denen Sie vertrauen.', + installApproveAndRetry: 'Lassen Sie diese Skripte zu und versuchen Sie es erneut', + installClose: 'Fertig', + close: 'Schließen', + cancel: 'Abbrechen', + confirmUninstallTitle: '„{name}“ deinstallieren?', + confirmUninstallDescription: 'Was es bietet, verschwindet, sobald es deinstalliert wird.', + confirmUninstall: 'Deinstallieren', + failedEnable: 'Konnte nicht aktiviert werden: {reason}', + failedDisable: 'Konnte nicht deaktiviert werden: {reason}', + failedUninstall: 'Konnte nicht deinstalliert werden: {reason}', + failedRowEnable: 'Die Komponente {reason} konnte nicht aktiviert werden', + failedRowDisable: 'Die Komponente {reason} konnte nicht deaktiviert werden', + reasonManagementRequired: 'Das Plugin-Management braucht es; Es kann nicht ausgeschaltet oder deinstalliert werden.', + reasonUnaddressable: 'Der Profil-Patch kann dieses Problem nicht eindeutig beheben.', + reasonUnknownPlugin: 'Kein solches Plugin.', + reasonInvalidSpec: 'Geben Sie einen gültigen Paketnamen oder eine gültige Paketadresse ein.', + reasonAlreadyInstalled: 'Dieses Paket ist bereits installiert; Deinstallieren Sie es, bevor Sie eine andere Version installieren.', + reasonAmbiguousInstall: 'Welches Paket installiert wurde, lässt sich anhand der Abhängigkeitsänderung nicht erkennen.', + reasonNotBundle: 'Dieses Paket deklariert kein Bundle und kann daher nicht als Plugin verwaltet werden.', + reasonNotRemovable: 'Dieses Paket gehört nicht dem Profil oder die Plugin-Verwaltung benötigt es.', + reasonStopProfile: 'Dieses Profil läuft ohne HMR; Stoppen Sie es und deinstallieren Sie das Paket mit dem dsh-Plugin.', + reasonBundleInUse: 'Andere Konfigurationen verwenden weiterhin die Komponenten dieses Bundles; Schalten Sie sie zuerst aus.', + reasonStaleApproval: 'Die ausstehenden Drehbuchgenehmigungen haben sich geändert; Installieren Sie es erneut, um sie zu aktualisieren.', + reasonIncompatibleVersion: '{plugin} ist nicht kompatibel mit DSH {runtime} (erfordert {requirements}); Die Ausführung kann zu Abstürzen oder Datenverlust führen.', + reasonIncompatibleVersionUnnamed: 'Dieses Plugin ist nicht mit der laufenden DSH-Version kompatibel. Die Ausführung kann zu Abstürzen oder Datenverlust führen.', + reasonIncompatibleInstall: 'Installieren Sie eine mit diesem DSH kompatible Plugin-Version.', + reasonIncompatibleInstalled: 'Deinstallieren Sie es und installieren Sie eine mit diesem DSH kompatible Version.', + reasonOperationError: 'Der Host hat einen Fehler gemeldet.', + detailTitle: 'Details', + detailLoading: 'Details lesen', + detailTooLarge: 'Die Details überschreiten die Größenbeschränkung und wurden nicht geladen.', + detailNetwork: 'Die Quelle der Details konnte nicht erreicht werden.', + detailInvalid: 'Das Plugin deklariert eine ungültige Detaildatei.', + detailFailed: 'Die Details konnten nicht gelesen werden.', + detailFootnotes: 'Fußnoten', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + reasonUnsupportedPlatform: 'Этот плагин не поддерживает операционную систему, в которой работает DSH.', + panel: 'Плагины', + title: 'Плагины', + intro: 'Установите, включите и настройте плагины', + infoLabel: 'О плагинах', + infoDescription: 'Настройте официальные плагины, а также установите или управляйте другими плагинами здесь. Посмотреть список встроенных плагинов и статус выполнения можно в разделе «Настройки» → «Встроенные плагины».', + loading: 'Чтение плагинов…', + error: 'Не удалось прочитать все плагины, возможно, из-за проблем с сетью.', + unavailable: 'Это развертывание выполняется без управляемого профиля, поэтому здесь нельзя устанавливать или переключать плагины.', + retry: 'Повторить', + refresh: 'Обновить', + refreshError: 'Обновить не удалось. Пожалуйста, попробуйте еще раз.', + empty: 'Плагины пока не установлены.', + addPlugin: 'Добавить плагин', + chooseAddMethod: 'Выберите, как добавить плагин', + installExisting: 'Установите сторонний плагин', + installExistingDescription: 'Поддерживает пакеты npm, репозитории Git и локальные каталоги.', + restartNotice: 'Перезапустите DSH, чтобы применить изменения. {reason}', + restartReasonNoHmr: 'Этот профиль применяет изменения только при запуске.', + restartReasonPackageMoved: 'Установленный пакет теперь разрешается в другой каталог, или изменение заменяет пакет, предоставляемый установкой DSH. Удалите пакет перед установкой его из другого места.', + restartReasonHostModule: 'Изменение доходит до загруженных модулей DSH.', + restartReasonUnmanagedPlugin: 'Изменение достигает плагина, работающего за пределами списка плагинов.', + restartReasonModuleComparison: 'DSH не смог сравнить загруженные плагины с их файлами.', + overriddenNotice: '{name} был сохранен, но конфигурация с более высоким приоритетом переопределяет его, поэтому он не действует.', + bundlesTitle: 'Установленные плагины', + basicTitle: 'Встроенные настройки плагина', + extensionsTitle: 'Экспериментальные плагины', + moreTitle: 'Экспериментальные плагины', + moreAction: 'Ещё', + tabsLabel: 'Просмотры плагинов', + installedTab: 'Установлено', + statusProblem: 'Проблема', + statusBeta: 'Экспериментальный', + reasonLabel: 'Причина', + metadataError: 'Ошибка метаданных пакета: {error}.', + versionTag: 'v{version}', + partsLabel: 'Компоненты', + sourceTitle: 'Источник', + sourceSpec: 'Источник кода', + sourceBuiltIn: 'Встроенный', + sourceVersion: 'Текущая версия', + partsEmpty: 'Этот пакет плагинов не содержит компонентов.', + partsCountTotal: '{count} всего', + partsCountRunning: '{count} работает', + partsCountOff: '{count} выкл.', + partOff: 'Выкл.', + partsCountFailed: '{count} не удалось', + partsFilter: 'Компоненты фильтра', + partsFilterEmpty: 'Ни один компонент не соответствует.', + partToggle: 'Включить компонент {name}', + rowPhasePending: 'Ожидание зависимостей', + rowPhaseLoading: 'Загрузка', + rowPhaseActive: 'Бег', + rowPhaseFailed: 'Проблема', + rowPhaseUnloading: 'Разгрузка', + enableToggle: 'Включить {name}', + applyingChange: 'Применение изменений в {name}', + openDetail: 'Посмотреть {name}', + backToList: 'Вернуться к плагинам', + crumbRoot: 'Плагины', + backToPackage: 'Вернуться к {name}', + configureRow: 'Настроить {name}', + rowStateIdle: 'Не работает', + uninstall: 'Удалить', + uninstallLabel: 'Удалить {name}', + installTitle: 'Добавить плагин', + installDescription: 'Введите имя пакета плагина, адрес репозитория GitHub или путь к локальному каталогу.', + installSpecLabel: 'Название или адрес пакета', + installSpecPlaceholder: 'например dsh-plugin-whale-pet', + installGuideToggle: 'Руководство по установке и примеры', + installGuideHide: 'Скрыть руководство', + installGuideIdTitle: 'Введите имя пакета npm плагина.', + installGuideIdExample: 'dsh-плагин-кит-питомец', + installGuideIdHint: 'Имя пакета плагина — это имя пакета npm (например, dsh-xxx или @author/plugin): часть после добавления плагина dsh или добавления pnpm в команде установки README плагина сообщества.', + installGuideExampleLabel: 'Пример: ', + installGitTemplateHint: 'Замените это фактическим адресом репозитория Git.', + installPathTemplateHint: 'Замените это фактическим путем к вашему локальному каталогу плагина.', + installGuideFill: 'Используйте пример', + installGuideFillAria: 'Используйте пример {example}', + installGuideSafety: 'Устанавливайте только те плагины, которым вы доверяете: они запускаются с вашими разрешениями и могут повредить DeepSeek Harness или привести к утечке ваших данных.', + installUpgradeNotice: 'Официальные плагины по требованию предлагают обновление, если их версия отличается от DSH. Чтобы обновить другие плагины, удалите и переустановите их.', + registryToggle: 'Реестр', + registryLegend: 'Реестр npm, из которого загружается плагин.', + registryDefault: 'Реестр по умолчанию', + registryOfficial: 'Официальный реестр npm', + registryNpmmirror: 'Зеркало материкового Китая', + registryCustom: 'Пользовательский адрес', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Введите внутренний или частный адрес реестра npm, начинающийся с http:// или https://.. Если требуется вход в систему, сохраните учетные данные в ~/.npmrc на этом компьютере.', + registryCustomInvalid: 'Введите адрес, начинающийся с http:// или https://.', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Установить', + installChecking: 'Проверка…', + installConfirmedTitle: 'Установить плагин', + installConfirmedRefused: 'Этот плагин не может быть установлен', + installProblemInvalid: 'Это не имя или адрес пакета, который можно установить: {reason}.', + installProblemInstalled: 'Этот плагин уже установлен. Чтобы обновить его, удалите его и установите снова.', + installProblemShipped: 'Этот плагин поставляется с DSH; обновление DSH обновляет его', + installBundledTitle: 'Этот плагин поставляется с DSH.', + installBundledDescription: 'Включите его, не загружая другую копию.', + installProblemNotFound: 'Такой плагин не найден', + installProblemNotPackage: 'Путь не существует или не является допустимым пакетом плагина.', + installProblemNotBundle: 'Этот пакет не декларирует отсутствие пакета, поэтому его нельзя установить как плагин: {reason}', + installProblemNetwork: 'Не удалось получить доступ к реестру плагинов; проверьте сеть и попробуйте еще раз', + installProblemNetworkAll: 'Не удалось получить доступ к реестру (пробовал: {registries}); проверьте настройки сети или прокси или измените реестр', + installProblemUnknown: 'Не удалось найти плагин: {reason}.', + installingTitle: 'Установка плагина…', + installedTitle: 'Установлено', + statusNotInstalled: 'Не установлено', + statusMissingFiles: 'Пакет недоступен', + statusUpdateAvailable: 'Доступно обновление', + sourceNotInstalled: 'Не установлено', + sourceTargetSpec: 'Источник для этого DSH', + sourceTargetVersion: 'Версия для этого DSH', + update: 'Обновить', + updateLabel: 'Обновление {name}', + versionsTitle: 'Версии', + versionsRecheck: 'Проверьте еще раз', + versionsChecking: 'Проверка обновлений', + versionsAvailable: 'Доступно обновление до {version}.', + versionsPublished: 'Опубликовано {date}', + versionsLatest: 'Актуально', + versionsCheckedAt: 'Последняя проверка на {time}.', + versionsBlocked: 'Для более новых версий требуется обновление DSH.', + versionsBlockedDetail: '{version} нужен {peers}; этот DSH - это {runtime}', + versionsNoneInstallable: 'Нет более новой версии для установки напрямую', + versionsNoneInstallableDetail: 'Более новые версии устарели, являются предварительными выпусками или не содержат пакетов. Разверните, чтобы увидеть их', + versionsFailedNetwork: 'Не удалось войти в реестр', + versionsFailedOther: 'Не удалось проверить наличие обновлений', + versionsDetails: 'Подробности', + versionsNewerCount: '{count} новее', + versionsCollapse: 'Свернуть', + versionsInstalledTag: 'Установлено', + versionsPrereleaseTag: 'Пререлиз', + versionsDeprecatedTag: 'Устарело', + versionsNeedsDsh: 'Требуется {peers}', + versionsNotBundle: 'Эта версия не декларирует отсутствие пакета и не может быть установлена.', + versionsUpdateLabel: 'Обновление до {version}', + versionsRegistry: 'Реестр', + versionsCheckedAtLabel: 'Проверено в', + versionsFallbackTag: 'Резервный вариант', + versionsFallbackHint: '{registry} не удалось связаться; список взят из резервного реестра', + versionsPrimaryTag: 'Первый', + versionsShowPrerelease: 'Показать пререлизы', + versionsOmitted: '{count} больше версий новее установленной не указано', + versionsProblemNetwork: 'недостижимый', + versionsProblemNotFound: 'Не найден', + versionsProblemUnknown: 'Не удалось', + updatesAvailable: 'Доступные обновления: {count}', + updatesNone: 'Все плагины актуальны', + updatesFailed: 'Некоторые плагины не удалось проверить', + updateTitle: 'Обновление {name}', + updateSource: 'Установить из', + updateDescription: 'Обновление заменяет {from} на {to}; плагин может ненадолго остановиться. Если установка не удалась, {from} сохраняется или устанавливается снова.', + updateRunning: 'Обновление', + updateStepChecking: 'Проверьте {version}', + updateStepRemoving: 'Остановите и удалите {version}.', + updateStepInstalling: 'Установите {version} из {registry}.', + updateStepRestoring: 'Установите {version} еще раз.', + updateDone: 'Обновлено до {version}.', + updateDoneDetail: 'Новая версия работает.', + updateRestart: '{version} установлен; оно вступит в силу после перезагрузки', + updateRefused: 'Не могу обновиться до {version}.', + updateRemoveFailed: 'Не удалось удалить {version}.', + updateRestored: 'Обновление не удалось; восстановлен {version}', + updateRestoredDetail: '{to} не удалось установить; {from} был установлен снова.', + updateKept: 'Не удалось обновиться до {version}.', + updateKeptDetail: '{from} не был удален и продолжает использоваться. {reason}', + updateBroken: 'Обновление не удалось; плагин не установлен', + updateBrokenDetail: '{to} не удалось установить, и повторная установка {from} также не удалась. Установите {name} снова из меню «Добавить плагин».', + updateLost: 'Результат обновления неизвестен', + updateLostDetail: 'Соединение с DSH было прервано: {reason}. Обновите список плагинов, чтобы увидеть установленную версию.', + updateOutput: 'Показать выходные данные установки', + updateFinish: 'Готово', + installFailedTitle: 'Плагин не удалось установить', + installGithubFailedTitle: 'Не могу получить доступ к GitHub', + installGithubTimeoutTitle: 'Время ожидания соединения GitHub истекло', + installGithubFailedDescription: 'Попробуйте другой источник установки.', + installUseGithubMirror: 'Используйте зеркало материкового Китая.', + installTryAnotherWay: 'Попробуйте другой способ', + installPackageLabel: 'Название пакета плагина', + installEdit: 'Редактировать', + installEditAria: 'Вернуться к редактированию', + installCancelAndEdit: 'Отменить установку и вернуться к редактированию', + installApplyingCancellationError: 'Отмена не подтвердилась. Установка применяется; дождитесь его результата. {reason}', + installReconcile: 'Проверьте статус установки', + installUnknownTitle: 'Результат установки недоступен', + installUnknownDescription: 'На хосте нет активной установки с этим идентификатором запроса. Прежде чем повторить попытку, проверьте список плагинов.', + installResultUnconfirmed: 'Результат установки не получен. Проверьте статус установки. {reason}', + installAwaitingAcceptance: 'Ожидание, пока хост примет установку. Отмена будет повторена автоматически после подтверждения.', + installBackgroundUnknown: 'Результат установки недоступен. Проверьте список плагинов.', + installCancel: 'Отменить установку', + installCloseCancels: 'Отменить установку и закрыть', + installViewTask: 'Посмотреть установку', + installUnconfirmedTitle: 'Статус установки не подтвержден', + installBackgroundDone: 'Установка завершена. Посмотреть подробности установки.', + installBackgroundFailed: 'Установка не удалась. Посмотреть подробности установки.', + installBackgroundUnconfirmed: 'Статус установки не подтвержден. Подробности смотрите в установке.', + installBackgroundApplying: 'Установка применяется и не может быть отменена. Просмотр хода установки.', + installStarting: 'Подготовка к установке…', + installCancelling: 'Остановка установки…', + installApplying: 'Применяется конфигурация, пожалуйста, подождите…', + installCancelledShort: 'Отменено', + installCancelled: 'Установка отменена; плагин не включен и скачанные файлы могут остаться', + installCancelUnconfirmed: 'Установка не подтверждена, остановлена. Повторите попытку отмены или дождитесь результата установки. {reason}', + installEnableNow: 'Включить сейчас', + installDetailsShow: 'Показать детали установки', + installDetailsHide: 'Скрыть сведения об установке', + installVersion: 'Версия {version}', + installSubjectPath: 'Локальный каталог', + installSubjectGit: 'Git-репозиторий', + installSubjectTarball: 'Тарбол', + installLocation: 'Устанавливается в {dir}', + installRetry: 'Повторить', + installChangeRegistry: 'Изменить реестр', + installAttempt: '{previous} не смог обслужить пакет; повторная попытка через {registry} (реестр {index} из {total})', + installAttemptBadge: 'Попытка {index} · {registry}', + installFailureNetwork: 'Сетевое подключение не удалось', + installFailureNetworkAll: 'Не удалось достичь реестра (пробовал: {registries}). Проверьте настройки сети или прокси-сервера или измените реестр и повторите попытку.', + installFailureNetworkHost: 'Не удалось связаться с {host}. Адрес GitHub или ссылка .tgz не извлекаются через реестр: этот компьютер должен получить доступ к ним напрямую или через прокси. Если плагин также опубликован в npm, вместо этого введите имя его пакета.', + installFailureNotFound: 'Такой плагин не найден', + installFailureNoMatchingVersion: 'Ни одна версия не соответствует запросу', + installFailureDiskFull: 'Диск заполнен; установка остановлена', + installFailurePermission: 'Нет разрешения на запись; плагин не может быть установлен', + installFailureBuildBlocked: 'Для продолжения установки сценариям установки зависимости требуется ваше разрешение.', + installFailureBuildBlockedManual: 'pnpm блокировал сценарии установки; разрешите их в разделеallowBuilds в pnpm-workspace.yaml и повторите попытку.', + installFailureIntegrity: 'Загруженный пакет не прошел проверку целостности.', + installFailureTimeout: 'Время установки истекло', + installFailurePnpmMissing: 'pnpm не найден, поэтому ничего установить невозможно', + installFailureGeneric: 'Во время установки что-то пошло не так; детали говорят о чем', + terminalCommandLine: 'Командная строка {n}', + terminalRunning: 'Бег', + terminalFailed: 'Не удалось', + terminalDone: 'Готово', + terminalCopy: 'Копировать', + terminalCopied: 'Скопировано', + terminalNoOutput: 'Нет вывода', + terminalCollapseAria: 'Свернуть вывод', + terminalCollapse: 'Свернуть', + terminalExpandAria: 'Разверните оставшиеся выходные строки {n}.', + terminalExpand: '… {n} еще строки', + terminalExitCode: 'код выхода {code}', + terminalSignal: 'сигнал {signal}', + terminalNoExitCode: 'нет кода выхода', + installDoneNothing: 'Установка завершена без новых зависимостей.', + installDoneRestart: 'Установлен; перезапустите DSH, чтобы загрузить его. {reason}', + installDoneApproved: 'Скрипты установки разрешены для {names}', + installDoneOtherVersion: 'В соответствии с политикой безопасности цепочки поставок pnpm (minimumReleaseAge) версии, опубликованные позже требуемого возраста, не устанавливаются автоматически, поэтому в этой установке использовалась {installed} вместо последней версии {version}. Недавно опубликованная версия не завершила этот период ожидания и может содержать необнаруженный вредоносный код или дефекты. Если вам нужен {version}, удалите текущую версию, а затем установите {exact}, чтобы запросить эту версию.', + installApprovalTitle: 'Для установки скриптов требуется разрешение', + installApprovalDescription: 'Эти пакеты содержат сценарии установки, которые pnpm не запускал.', + installApprovalConsequence: 'После разрешения сценарии запускаются здесь с вашими разрешениями, а разрешение сохраняется в этом профиле.', + installApprovalCaution: 'Разрешите только те пакеты, которым вы доверяете.', + installApproveAndRetry: 'Разрешите эти сценарии и повторите попытку.', + installClose: 'Готово', + close: 'Закрыть', + cancel: 'Отмена', + confirmUninstallTitle: 'Удалить "{name}"?', + confirmUninstallDescription: 'То, что он предоставляет, исчезает после его удаления.', + confirmUninstall: 'Удалить', + failedEnable: 'Не удалось включить: {reason}', + failedDisable: 'Не удалось отключить: {reason}', + failedUninstall: 'Не удалось удалить: {reason}', + failedRowEnable: 'Не удалось включить компонент: {reason}.', + failedRowDisable: 'Не удалось отключить компонент: {reason}.', + reasonManagementRequired: 'Управление плагинами нуждается в этом; его нельзя отключить или удалить.', + reasonUnaddressable: 'Патч профиля не может решить эту проблему однозначно.', + reasonUnknownPlugin: 'Нет такого плагина.', + reasonInvalidSpec: 'Введите действительное имя или адрес пакета.', + reasonAlreadyInstalled: 'Этот пакет уже установлен; удалите его перед установкой другой версии.', + reasonAmbiguousInstall: 'Какой пакет был установлен, невозможно определить по изменению зависимостей.', + reasonNotBundle: 'В этом пакете не объявлен пакет, поэтому им нельзя управлять как плагином.', + reasonNotRemovable: 'Этот пакет не принадлежит профилю, или он нужен управлению плагинами.', + reasonStopProfile: 'Этот профиль работает без HMR; остановите его и удалите пакет с плагином dsh.', + reasonBundleInUse: 'Другая конфигурация по-прежнему использует компоненты этого пакета; сначала выключите их.', + reasonStaleApproval: 'Ожидаемые утверждения сценария изменились; установите еще раз, чтобы обновить их.', + reasonIncompatibleVersion: '{plugin} несовместим с DSH {runtime} (требуется {requirements}); его запуск может привести к сбоям или потере данных.', + reasonIncompatibleVersionUnnamed: 'Этот плагин несовместим с работающей версией DSH; его запуск может привести к сбоям или потере данных.', + reasonIncompatibleInstall: 'Установите версию плагина, совместимую с этим DSH.', + reasonIncompatibleInstalled: 'Удалите его и установите версию, совместимую с этим DSH.', + reasonOperationError: 'Хост сообщил об ошибке.', + detailTitle: 'Подробности', + detailLoading: 'Читаем подробности', + detailTooLarge: 'Детали превышают лимит размера и не были загружены.', + detailNetwork: 'До источника подробностей добраться не удалось.', + detailInvalid: 'Плагин объявляет неверный файл сведений.', + detailFailed: 'Подробности прочитать не удалось.', + detailFootnotes: 'Сноски', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + reasonUnsupportedPlatform: 'Este plugin não oferece suporte ao sistema operacional em que o DSH está sendo executado.', + panel: 'Plug-ins', + title: 'Plug-ins', + intro: 'Instalar, habilitar e configurar plug-ins', + infoLabel: 'Sobre plug-ins', + infoDescription: 'Configure plugins oficiais e instale ou gerencie outros plugins aqui. Veja a lista de plug-ins integrados e o status do tempo de execução em Configurações → Plug-ins integrados.', + loading: 'Lendo plug-ins…', + error: 'Não foi possível ler todos os plugins, possivelmente devido a um problema de rede', + unavailable: 'Esta implantação é executada sem um perfil gerenciável, portanto, os plug-ins não podem ser instalados ou trocados aqui.', + retry: 'Tentar novamente', + refresh: 'Atualizar', + refreshError: 'Falha na atualização. Por favor, tente novamente.', + empty: 'Nenhum plug-in está instalado ainda.', + addPlugin: 'Adicionar plug-in', + chooseAddMethod: 'Escolha como adicionar um plugin', + installExisting: 'Instale um plugin de terceiros', + installExistingDescription: 'Suporta pacotes npm, repositórios Git e diretórios locais', + restartNotice: 'Reinicie DSH para aplicar a alteração. {reason}', + restartReasonNoHmr: 'Este perfil aplica alterações somente na inicialização.', + restartReasonPackageMoved: 'Um pacote instalado agora é resolvido para outro diretório ou a alteração substitui um pacote fornecido pela instalação DSH. Desinstale um pacote antes de instalá-lo de outro local.', + restartReasonHostModule: 'A mudança atinge os módulos DSH carregados.', + restartReasonUnmanagedPlugin: 'A mudança atinge um plugin rodando fora da lista de plugins.', + restartReasonModuleComparison: 'DSH não conseguiu comparar os plug-ins carregados com seus arquivos.', + overriddenNotice: '{name} foi salvo, mas uma configuração de prioridade mais alta o substitui, portanto não está em vigor', + bundlesTitle: 'Plug-ins instalados', + basicTitle: 'Configurações de plug-in integradas', + extensionsTitle: 'Plug-ins experimentais', + moreTitle: 'Plug-ins experimentais', + moreAction: 'Mais', + tabsLabel: 'Visualizações de plug-in', + installedTab: 'Instalado', + statusProblem: 'Problema', + statusBeta: 'Experimental', + reasonLabel: 'Razão', + metadataError: 'Erro de metadados do pacote: {error}', + versionTag: 'v{version}', + partsLabel: 'Componentes', + sourceTitle: 'Fonte', + sourceSpec: 'Fonte do código', + sourceBuiltIn: 'Construído em', + sourceVersion: 'Versão atual', + partsEmpty: 'Este pacote de plugins não contém componentes.', + partsCountTotal: '{count} total', + partsCountRunning: '{count} em execução', + partsCountOff: '{count} desativado', + partOff: 'Desligado', + partsCountFailed: '{count} falhou', + partsFilter: 'Filtrar componentes', + partsFilterEmpty: 'Nenhum componente corresponde.', + partToggle: 'Ativar componente {name}', + rowPhasePending: 'Esperando por dependências', + rowPhaseLoading: 'Carregando', + rowPhaseActive: 'Correndo', + rowPhaseFailed: 'Problema', + rowPhaseUnloading: 'Descarregando', + enableToggle: 'Ativar {name}', + applyingChange: 'Aplicando alterações em {name}', + openDetail: 'Ver {name}', + backToList: 'Voltar aos plug-ins', + crumbRoot: 'Plug-ins', + backToPackage: 'Voltar para {name}', + configureRow: 'Configurar {name}', + rowStateIdle: 'Não está em execução', + uninstall: 'Desinstalar', + uninstallLabel: 'Desinstalar {name}', + installTitle: 'Adicionar plug-in', + installDescription: 'Insira o nome do pacote do plug-in, o endereço do repositório GitHub ou o caminho do diretório local.', + installSpecLabel: 'Nome ou endereço do pacote', + installSpecPlaceholder: 'por exemplo dsh-plugin-baleia-animal de estimação', + installGuideToggle: 'Guia de instalação e exemplos', + installGuideHide: 'Ocultar o guia', + installGuideIdTitle: 'Digite o nome do pacote npm do plugin', + installGuideIdExample: 'dsh-plugin-baleia-animal de estimação', + installGuideIdHint: 'O nome do pacote do plug-in é o nome do pacote npm (como dsh-xxx ou @author/plugin): a parte após dsh plugin add ou pnpm add no comando de instalação README de um plug-in da comunidade.', + installGuideExampleLabel: 'Exemplo: ', + installGitTemplateHint: 'Substitua pelo endereço real do repositório Git.', + installPathTemplateHint: 'Substitua isso pelo caminho real para o diretório local do plugin.', + installGuideFill: 'Exemplo de uso', + installGuideFillAria: 'Use o exemplo {example}', + installGuideSafety: 'Instale apenas plug-ins em que você confia: eles são executados com suas permissões e podem danificar DeepSeek Harness ou vazar seus dados.', + installUpgradeNotice: 'Plug-ins oficiais sob demanda oferecem atualização quando sua versão é diferente de DSH. Para atualizar outros plug-ins, desinstale-os e reinstale-os.', + registryToggle: 'Registro', + registryLegend: 'O registro npm do qual o plugin é baixado', + registryDefault: 'Registro padrão', + registryOfficial: 'Registro oficial do npm', + registryNpmmirror: 'Espelho da China Continental', + registryCustom: 'Endereço personalizado', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Insira um endereço de registro npm interno ou privado começando com http:// ou https://. Se for necessário um login, armazene as credenciais em ~/.npmrc nesta máquina.', + registryCustomInvalid: 'Insira um endereço começando com http:// ou https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Instalar', + installChecking: 'Verificando…', + installConfirmedTitle: 'Instalar plug-in', + installConfirmedRefused: 'Este plugin não pode ser instalado', + installProblemInvalid: 'Este não é um nome ou endereço de pacote que pode ser instalado: {reason}', + installProblemInstalled: 'Este plugin já está instalado. Para atualizá-lo, desinstale-o e instale-o novamente', + installProblemShipped: 'Este plug-in é fornecido com DSH; atualizar DSH atualiza-o', + installBundledTitle: 'Este plug-in é fornecido com DSH', + installBundledDescription: 'Habilite-o sem baixar outra cópia', + installProblemNotFound: 'Nenhum plugin desse tipo foi encontrado', + installProblemNotPackage: 'O caminho não existe ou não é um pacote de plugin válido', + installProblemNotBundle: 'Este pacote não declara nenhum pacote, portanto não pode ser instalado como um plug-in: {reason}', + installProblemNetwork: 'O registro do plugin não pôde ser acessado; verifique a rede e tente novamente', + installProblemNetworkAll: 'Nenhum registro pôde ser acessado (tentativa: {registries}); verifique as configurações de rede ou proxy ou altere o registro', + installProblemUnknown: 'Não foi possível pesquisar o plug-in: {reason}', + installingTitle: 'Instalando o plug-in…', + installedTitle: 'Instalado', + statusNotInstalled: 'Não instalado', + statusMissingFiles: 'Pacote indisponível', + statusUpdateAvailable: 'Atualização disponível', + sourceNotInstalled: 'Não instalado', + sourceTargetSpec: 'Fonte para este DSH', + sourceTargetVersion: 'Versão para este DSH', + update: 'Atualizar', + updateLabel: 'Atualizar {name}', + versionsTitle: 'Versões', + versionsRecheck: 'Verifique novamente', + versionsChecking: 'Verificando atualizações', + versionsAvailable: 'Atualização para {version} disponível', + versionsPublished: 'Publicado {date}', + versionsLatest: 'Atualizado', + versionsCheckedAt: 'Última verificação em {time}', + versionsBlocked: 'Versões mais recentes precisam de uma atualização DSH', + versionsBlockedDetail: '{version} precisa de {peers}; este DSH é {runtime}', + versionsNoneInstallable: 'Nenhuma versão mais recente para instalar diretamente', + versionsNoneInstallableDetail: 'As versões mais recentes estão obsoletas, são pré-lançamentos ou não declaram nenhum pacote. Expanda para vê-los', + versionsFailedNetwork: 'Não foi possível acessar o registro', + versionsFailedOther: 'Não foi possível verificar atualizações', + versionsDetails: 'Detalhes', + versionsNewerCount: '{count} mais recente', + versionsCollapse: 'Recolher', + versionsInstalledTag: 'Instalado', + versionsPrereleaseTag: 'Pré-lançamento', + versionsDeprecatedTag: 'Obsoleto', + versionsNeedsDsh: 'Precisa de {peers}', + versionsNotBundle: 'Esta versão não declara nenhum pacote e não pode ser instalada', + versionsUpdateLabel: 'Atualizar para {version}', + versionsRegistry: 'Registro', + versionsCheckedAtLabel: 'Verificado em', + versionsFallbackTag: 'Alternativa', + versionsFallbackHint: '{registry} não pôde ser contatado; a lista vem de um registro substituto', + versionsPrimaryTag: 'Primeiro', + versionsShowPrerelease: 'Mostrar pré-lançamentos', + versionsOmitted: '{count} mais versões mais recentes que a instalada não estão listadas', + versionsProblemNetwork: 'Inacessível', + versionsProblemNotFound: 'Não encontrado', + versionsProblemUnknown: 'Falha', + updatesAvailable: 'Atualizações disponíveis: {count}', + updatesNone: 'Todos os plug-ins estão atualizados', + updatesFailed: 'Alguns plug-ins não puderam ser verificados', + updateTitle: 'Atualizar {name}', + updateSource: 'Instalar de', + updateDescription: 'A atualização substitui {from} por {to}; o plugin pode parar brevemente. Se a instalação falhar, {from} será mantido ou instalado novamente.', + updateRunning: 'Atualizando', + updateStepChecking: 'Verifique {version}', + updateStepRemoving: 'Pare e remova {version}', + updateStepInstalling: 'Instalar {version} de {registry}', + updateStepRestoring: 'Instale {version} novamente', + updateDone: 'Atualizado para {version}', + updateDoneDetail: 'A nova versão está em execução.', + updateRestart: '{version} instalado; entra em vigor após uma reinicialização', + updateRefused: 'Não é possível atualizar para {version}', + updateRemoveFailed: 'Não foi possível remover {version}', + updateRestored: 'Falha na atualização; restaurado {version}', + updateRestoredDetail: '{to} falhou ao instalar; {from} foi instalado novamente.', + updateKept: 'Não foi possível atualizar para {version}', + updateKeptDetail: '{from} não foi removido e permanece em uso. {reason}', + updateBroken: 'Falha na atualização; o plugin não está instalado', + updateBrokenDetail: '{to} falhou ao instalar e instalar {from} novamente também falhou. Instale {name} novamente em Adicionar plug-in.', + updateLost: 'O resultado da atualização é desconhecido', + updateLostDetail: 'A conexão com DSH foi interrompida: {reason}. Atualize a lista de plugins para ver a versão instalada.', + updateOutput: 'Mostrar resultado da instalação', + updateFinish: 'Concluído', + installFailedTitle: 'O plugin não pôde ser instalado', + installGithubFailedTitle: 'Não é possível acessar GitHub', + installGithubTimeoutTitle: 'GitHub conexão expirou', + installGithubFailedDescription: 'Tente outra fonte de instalação.', + installUseGithubMirror: 'Use o espelho da China continental', + installTryAnotherWay: 'Tente outra maneira', + installPackageLabel: 'Nome do pacote de plug-ins', + installEdit: 'Editar', + installEditAria: 'Voltar para edição', + installCancelAndEdit: 'Cancele a instalação e retorne à edição', + installApplyingCancellationError: 'O cancelamento não foi confirmado. A instalação está sendo aplicada; espere pelo seu resultado. {reason}', + installReconcile: 'Verifique o status da instalação', + installUnknownTitle: 'Resultado da instalação indisponível', + installUnknownDescription: 'O Host não possui instalação ativa com este ID de solicitação. Verifique a lista de plugins antes de tentar novamente.', + installResultUnconfirmed: 'O resultado da instalação não foi recebido. Verifique o status da instalação. {reason}', + installAwaitingAcceptance: 'Aguardando o Host aceitar a instalação. O cancelamento será tentado novamente automaticamente após a confirmação.', + installBackgroundUnknown: 'Resultado da instalação indisponível. Verifique a lista de plugins.', + installCancel: 'Cancelar instalação', + installCloseCancels: 'Cancelar instalação e fechar', + installViewTask: 'Ver instalação', + installUnconfirmedTitle: 'Status de instalação não confirmado', + installBackgroundDone: 'Instalação concluída. Veja detalhes da instalação.', + installBackgroundFailed: 'A instalação falhou. Veja detalhes da instalação.', + installBackgroundUnconfirmed: 'O status da instalação não foi confirmado. Veja a instalação para obter detalhes.', + installBackgroundApplying: 'A instalação está sendo aplicada e não pode ser cancelada. Veja o progresso da instalação.', + installStarting: 'Preparando a instalação…', + installCancelling: 'Parando a instalação…', + installApplying: 'Aplicando configuração, aguarde…', + installCancelledShort: 'Cancelado', + installCancelled: 'Instalação cancelada; o plugin não está habilitado e os arquivos baixados podem permanecer', + installCancelUnconfirmed: 'A instalação não foi confirmada interrompida. Tente novamente o cancelamento ou aguarde o resultado da instalação. {reason}', + installEnableNow: 'Ativar agora', + installDetailsShow: 'Mostrar detalhes da instalação', + installDetailsHide: 'Ocultar detalhes de instalação', + installVersion: 'Versão {version}', + installSubjectPath: 'Diretório local', + installSubjectGit: 'Repositório Git', + installSubjectTarball: 'Tarball', + installLocation: 'Instala em {dir}', + installRetry: 'Tentar novamente', + installChangeRegistry: 'Alterar registro', + installAttempt: '{previous} não pôde entregar o pacote; tentando novamente através de {registry} (registro {index} de {total})', + installAttemptBadge: 'Tentativa {index} · {registry}', + installFailureNetwork: 'A conexão de rede falhou', + installFailureNetworkAll: 'Nenhum registro pôde ser acessado (tentativa: {registries}). Verifique as configurações de rede ou proxy ou altere o registro e tente novamente.', + installFailureNetworkHost: '{host} não pôde ser alcançado. Um endereço GitHub ou um link .tgz não é obtido através do registro: esta máquina deve acessá-lo diretamente ou através de um proxy. Se o plugin também for publicado no npm, insira o nome do pacote.', + installFailureNotFound: 'Nenhum plugin desse tipo foi encontrado', + installFailureNoMatchingVersion: 'Nenhuma versão corresponde à solicitação', + installFailureDiskFull: 'O disco está cheio; a instalação parou', + installFailurePermission: 'Sem permissão de gravação; o plugin não pode ser instalado', + installFailureBuildBlocked: 'Os scripts de instalação de uma dependência precisam da sua permissão antes que a instalação possa continuar', + installFailureBuildBlockedManual: 'scripts de instalação bloqueados por pnpm; permita-os em permitBuilds em pnpm-workspace.yaml e tente novamente', + installFailureIntegrity: 'O pacote baixado falhou na verificação de integridade', + installFailureTimeout: 'A instalação expirou', + installFailurePnpmMissing: 'pnpm não foi encontrado, então nada pode ser instalado', + installFailureGeneric: 'Algo deu errado durante a instalação; os detalhes dizem o que', + terminalCommandLine: 'Linha de comando {n}', + terminalRunning: 'Correndo', + terminalFailed: 'Falha', + terminalDone: 'Concluído', + terminalCopy: 'Copiar', + terminalCopied: 'Copiado', + terminalNoOutput: 'Sem saída', + terminalCollapseAria: 'Recolher saída', + terminalCollapse: 'Recolher', + terminalExpandAria: 'Expanda as {n} linhas de saída restantes', + terminalExpand: '… mais {n} linhas', + terminalExitCode: 'código de saída {code}', + terminalSignal: 'sinal {signal}', + terminalNoExitCode: 'sem código de saída', + installDoneNothing: 'A instalação foi concluída sem nenhuma nova dependência.', + installDoneRestart: 'Instalado; reinicie DSH para carregá-lo. {reason}', + installDoneApproved: 'Instalar scripts permitidos para {names}', + installDoneOtherVersion: 'De acordo com a política de segurança da cadeia de fornecimento do pnpm (minimumReleaseAge), as versões publicadas mais recentemente do que a idade exigida não são instaladas automaticamente, portanto, esta instalação usou {installed} em vez da versão mais recente {version}. Uma versão recém-publicada não completou esse período de espera e pode conter códigos maliciosos ou defeitos não detectados. Se precisar de {version}, desinstale a versão atual e instale {exact} para solicitar essa versão.', + installApprovalTitle: 'Os scripts de instalação precisam de permissão', + installApprovalDescription: 'Esses pacotes possuem scripts de instalação que o pnpm não executou.', + installApprovalConsequence: 'Uma vez permitido, os scripts são executados aqui com suas permissões, e a permissão é salva neste perfil.', + installApprovalCaution: 'Permita apenas pacotes em que você confia.', + installApproveAndRetry: 'Permita esses scripts e tente novamente', + installClose: 'Concluído', + close: 'Fechar', + cancel: 'Cancelar', + confirmUninstallTitle: 'Desinstalar "{name}"?', + confirmUninstallDescription: 'O que ele fornece desaparece quando é desinstalado.', + confirmUninstall: 'Desinstalar', + failedEnable: 'Não foi possível ativar: {reason}', + failedDisable: 'Não foi possível desativar: {reason}', + failedUninstall: 'Não foi possível desinstalar: {reason}', + failedRowEnable: 'Não foi possível ativar o componente: {reason}', + failedRowDisable: 'Não foi possível desativar o componente: {reason}', + reasonManagementRequired: 'O gerenciamento de plug-ins precisa disso; ele não pode ser desligado ou desinstalado.', + reasonUnaddressable: 'O patch de perfil não pode resolver este problema exclusivamente.', + reasonUnknownPlugin: 'Não existe tal plugin.', + reasonInvalidSpec: 'Insira um nome ou endereço de pacote válido.', + reasonAlreadyInstalled: 'Este pacote já está instalado; desinstale-o antes de instalar outra versão.', + reasonAmbiguousInstall: 'Qual pacote foi instalado não pode ser informado a partir da mudança de dependência.', + reasonNotBundle: 'Este pacote não declara nenhum pacote, portanto não pode ser gerenciado como um plugin.', + reasonNotRemovable: 'Este pacote não pertence ao perfil ou o gerenciamento do plugin precisa dele.', + reasonStopProfile: 'Este perfil é executado sem HMR; pare e desinstale o pacote com o plugin dsh.', + reasonBundleInUse: 'Outras configurações ainda usam os componentes deste pacote configurável; desligue-os primeiro.', + reasonStaleApproval: 'As aprovações de script pendentes foram alteradas; instale novamente para atualizá-los.', + reasonIncompatibleVersion: '{plugin} é incompatível com DSH {runtime} (requer {requirements}); executá-lo pode causar travamentos ou perda de dados.', + reasonIncompatibleVersionUnnamed: 'Este plug-in é incompatível com a versão DSH em execução; executá-lo pode causar travamentos ou perda de dados.', + reasonIncompatibleInstall: 'Instale uma versão de plug-in compatível com este DSH.', + reasonIncompatibleInstalled: 'Desinstale-o e instale uma versão compatível com este DSH.', + reasonOperationError: 'O Host relatou um erro.', + detailTitle: 'Detalhes', + detailLoading: 'Lendo detalhes', + detailTooLarge: 'Os detalhes excedem o limite de tamanho e não foram carregados.', + detailNetwork: 'Não foi possível acessar a fonte dos detalhes.', + detailInvalid: 'O plugin declara um arquivo de detalhes inválido.', + detailFailed: 'Não foi possível ler os detalhes.', + detailFootnotes: 'Notas de rodapé', +} satisfies Record diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 775cbf0212..5b1d29b3fa 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -44,6 +44,18 @@ import { shortName } from './presentation.ts' import { BundleDetailController, type BundleDetailsState } from './detail-store.ts' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +/** + * Read whether a bundle has a pending management action. + * @param state Current manager snapshot. + * @param packageName Bundle whose controls and observers must wait. + * @returns Whether the bundle's management operation is still pending. + */ +export function bundleBusy(state: PluginManagerState, packageName: string): boolean { + return state.busy.includes(packageName) || state.update?.packageName === packageName + || (state.install.subject?.name === packageName && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) + +} + /** The action a failed notice names. */ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable' @@ -101,6 +113,8 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile declares this dependency; availability separately describes its readable files. */ readonly installed: boolean + /** Default GUI visibility after package and Host checks; selected or profile-installed bundles remain manageable. */ + readonly listedByDefault?: boolean /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ @@ -113,7 +127,7 @@ export interface PackageView { readonly enabled: boolean /** Why the Host refuses to switch the bundle off or remove it, when it does. */ readonly readOnlyReason?: ReadOnlyReason - /** Why the Host cannot read the bundle, when it cannot. */ + /** Why the Host cannot read or enable the bundle. */ readonly error?: ManagementError readonly rows: readonly PackageRow[] } @@ -121,6 +135,8 @@ export interface PackageView { /** The typed spec as the Host read it, on the installing, installed, and failed screens. */ export type InstallSubject = Extract & { readonly spec: string + /** The installation already provides this bundle; only explicit enablement remains. */ + readonly bundled?: true /** Present for a catalog install or update: desired selection after installation. */ readonly selection?: boolean /** Save an exact dependency version through the generic installer. */ @@ -235,9 +251,9 @@ export interface InstallState { readonly runs: readonly InstallRun[] /** Whether the run's command and output are unfolded, including after reopening. */ readonly detailsOpen: boolean - /** The bundle the finished run added; ordinary installs await enablement, catalog operations carry their selection. */ + /** The bundle ready for enablement, from an install run or the installation; catalog operations carry their selection. */ readonly installed: string | null - /** The version the finished run installed, when its manifest declares one; null otherwise. */ + /** The available bundle's manifest version; null when undeclared. */ readonly installedVersion: string | null /** Whether the finished run's bundle waits for the next start to load. */ readonly restartRequired: boolean @@ -542,6 +558,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): availability: bundle.availability, ...bundle.installTarget === undefined ? {} : { installTarget: bundle.installTarget }, installed: bundle.installed, + ...bundle.listedByDefault === undefined ? {} : { listedByDefault: bundle.listedByDefault }, optional: bundle.optional, removable: bundle.removable, updatable: bundle.updatable, @@ -671,6 +688,22 @@ export class PluginManagerController { return this.store.getSnapshot() } + /** + * Observe existing bundle and row operations independently of the page's lifetime. + * @param packageName Bundle whose management activity is observed. + * @returns A read-only source; unsubscribe releases the caller's observation. + */ + observeChanges(packageName: string): HostObservable { + return { + getSnapshot: () => { + const state = this.getSnapshot() + return bundleBusy(state, packageName) || (state.packages.find(pkg => pkg.name === packageName)?.rows.some(row => + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))) ?? false) + }, + subscribe: listener => this.store.subscribe(listener), + } + } + /** Stop publishing and drop every late settlement. */ dispose(): void { this.disposed = true @@ -720,7 +753,7 @@ export class PluginManagerController { void this.cancelInstall() return } - if (this.getSnapshot().install.phase === 'checking') this.finishAnalytics('cancelled') + if (this.getSnapshot().install.phase === 'checking' || this.getSnapshot().install.subject?.bundled) this.finishAnalytics('cancelled') this.abortInspect() this.registryRead = undefined this.patch({ install: IDLE_INSTALL }) @@ -1119,9 +1152,9 @@ export class PluginManagerController { } /** - * Check the typed spec, then install it. The Host reads what the spec - * names first; a refused spec returns to the field with the reason, an - * accepted one becomes the subject the next screens show while pnpm runs. + * Offer a default-hidden shipped bundle for explicit enablement, or check + * and install the typed spec. A refused spec returns to the field with its + * reason; an accepted one becomes the subject shown while pnpm runs. */ private async runInstall(): Promise { const state = this.getSnapshot() @@ -1132,9 +1165,20 @@ export class PluginManagerController { this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() } this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) }) } - // A name the list already shows is refused at once, before the Host is asked. const listed = state.packages.find(pkg => pkg.name === spec) const catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject + if (listed?.availability === 'installation' && listed.optional && listed.listedByDefault === false + && !listed.enabled && listed.error === undefined && listed.readOnlyReason === undefined) { + this.abortInspect() + this.patch({ install: { + ...IDLE_INSTALL, open: true, spec, registries: install.registries, registry: install.registry, + phase: 'done', installed: listed.name, installedVersion: listed.version ?? null, + subject: { status: 'accepted', kind: 'registry', bundle: true, name: listed.name, spec, registry: null, bundled: true, + ...listed.version === undefined ? {} : { version: listed.version }, + ...listed.description === undefined ? {} : { description: listed.description } }, + } }) + return + } if (listed !== undefined && listed.availability !== 'missing' && catalogSubject === undefined) { this.finishAnalytics('failed', 'already-installed') this.patchInstall({ phase: 'idle', inputError: { problem: listed.availability === 'installation' ? 'shipped' : 'already-installed', reason: spec } }) @@ -1371,9 +1415,8 @@ export class PluginManagerController { } /** - * Enable the bundle the finished install added, then close the dialog and - * mark it in the list. A refusal toasts and still closes: the list shows - * what did not switch on. + * Enable the bundle ready from an install or the installation, then close the dialog and + * mark it in the list. A refusal toasts and still closes. */ private async enableInstalled(): Promise { const install = this.getSnapshot().install @@ -1383,6 +1426,8 @@ export class PluginManagerController { if (name !== null) { const result = await this.ctx.remote.pluginManager.setBundleEnabled(name, true) if (this.disposed) return + this.finishAnalytics(result.ok && result.value.application !== 'failed' ? 'success' : 'failed', + result.ok ? result.value.error?.code : result.error.code, name) try { this.applied(result, name) if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(name, true) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index b636c67e04..e532eadc9e 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,5 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ +import { EXPERIMENTAL_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { IncompatiblePlugin, ManagementError, Registry, RestartReason } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' @@ -58,6 +59,7 @@ const CODE_KEYS = { 'bundle-in-use': 'reasonBundleInUse', 'stale-approval': 'reasonStaleApproval', 'incompatible-version': 'reasonIncompatibleVersionUnnamed', + 'unsupported-platform': 'reasonUnsupportedPlatform', 'operation-error': 'reasonOperationError', } satisfies Record @@ -108,23 +110,6 @@ export function shortName(name: string): string { return unscoped.replace(/^dsh-(?:host-|client-)?/, '') } -/** Official bundles that display the Experimental badge. */ -const EXPERIMENTAL_BUNDLES: readonly string[] = [ - '@deepseek-ai/dsh-cua-bundle', - '@deepseek-ai/dsh-experimental-agent-team-profile', - '@deepseek-ai/dsh-experimental-voice-input-bundle', - '@deepseek-ai/dsh-experimental-auto-review', - '@deepseek-ai/dsh-experimental-inspector-profile', - '@deepseek-ai/dsh-experimental-tool-worktree', - '@deepseek-ai/dsh-experimental-cot-translation-bundle', - '@deepseek-ai/dsh-experimental-terminal-bundle', - '@deepseek-ai/dsh-experimental-session-search', - '@deepseek-ai/dsh-experimental-session-titles-bundle', - '@deepseek-ai/dsh-experimental-badge-skill-bundle', - '@deepseek-ai/dsh-experimental-ralph-bundle', - '@deepseek-ai/dsh-experimental-plugin-market-bundle', -] - /** * Resolve installed package metadata without changing its technical identity. * @param pkg - package identity and local metadata. @@ -137,7 +122,7 @@ export function packageText( return { title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title), description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined, - beta: EXPERIMENTAL_BUNDLES.includes(pkg.name), + beta: EXPERIMENTAL_BUNDLES.has(pkg.name), } } 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 04c9392b1e..2a6f019ad4 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -21,10 +21,53 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench() { +type AccessEffect = (() => void) | Iterable<() => void> + +/** One guarded callback, the Context that owns it, and the disposer of its current installation. */ +interface Guard { + readonly owner: Context + readonly callback: () => AccessEffect + dispose: (() => void) | undefined +} + +/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */ +class SwitchableAccess extends Service { + private allowed = true + private readonly guards: Guard[] = [] + + constructor(serviceCtx: Context) { + super(serviceCtx, 'remoteAccess') + } + + whenAccess(_action: string, callback: () => AccessEffect): () => void { + const guard: Guard = { owner: this.ctx, callback, dispose: undefined } + this.guards.push(guard) + this.install(guard) + return () => {} + } + + /** @param allowed - the answer every guarded effect follows from now on. */ + set(allowed: boolean): void { + this.allowed = allowed + for (const guard of this.guards) { + if (allowed) this.install(guard) + else { guard.dispose?.(); guard.dispose = undefined } + } + } + + private install(guard: Guard): void { + if (!this.allowed || guard.dispose !== undefined) return + const dispose = guard.owner.effect(guard.callback) + guard.dispose = () => { void dispose() } + } +} + +async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() + const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined + if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -50,7 +93,7 @@ async function bench() { ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal, toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access } } function declare(slots: SlotRegistry): () => void { @@ -124,6 +167,7 @@ describe('ui-plugin-manager browser plugin', () => { const removeRoot = declare(b.slots) const mainInjected: object = b.slots.entries('main')[0]!.inject!() const face = mainInjected as PluginManagerFace + expect(b.ctx.pluginChanges.bundle('unlisted').getSnapshot()).toBe(false) const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() const overlay = overlayInjected as PluginRefreshToastFace expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager) @@ -152,7 +196,41 @@ describe('ui-plugin-manager browser plugin', () => { }) it('declares only the services the page and its Remote methods use', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) + }) + + it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => { + const b = await bench('switchable') + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected: object = b.slots.entries('main')[0]!.inject!() + const face = injected as PluginManagerFace + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(false) + expect(b.slots.entries('main')).toEqual([]) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(true) + expect(b.slots.entries('main')).toHaveLength(1) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + }) + + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('pluginManager/listBundles') + for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([]) + expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false) + await fiber.dispose() }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { @@ -214,15 +292,16 @@ describe('ui-plugin-manager browser plugin', () => { face.ensure() await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) expect(b.list).toHaveBeenCalledTimes(1) - // Bundle detail reads go to the Host by name; a profile change forgets them. + // A profile change invalidates reads without withdrawing the displayed document. face.loadDetail('dsh-a', '1.0.0', 'en', false) - await vi.waitFor(() => { expect(face.hooks.bundleDetails.getSnapshot()).not.toEqual({}) }) + await vi.waitFor(() => { expect(Object.values(face.hooks.bundleDetails.getSnapshot())[0]?.status).toBe('ready') }) + const loadedDetails = face.hooks.bundleDetails.getSnapshot() face.retryDetail('dsh-a', '1.0.0', 'en', false) expect(face.detailImage('dsh-a', 'README.md', 'a.png', false)).toContain('api/plugin-manager/detail-file?bundle=dsh-a') expect(face.hooks.locale.getSnapshot().active).toBe('zh') b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }]) await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) - expect(face.hooks.bundleDetails.getSnapshot()).toEqual({}) + expect(face.hooks.bundleDetails.getSnapshot()).toEqual(loadedDetails) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) }) @@ -238,6 +317,7 @@ describe('ui-plugin-manager browser plugin', () => { await fiber.dispose() expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) expect(b.slots.entries('shell.overlay')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) 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 f35ef15330..b320b97190 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -162,6 +162,7 @@ function renderTab( } const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { + createHeaderRef: vi.fn(() => vi.fn()), useStore: bindSnapshotSelector(navigation), actions: navigation.actions, ...standard, t, @@ -240,10 +241,13 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('tablist')).toBeNull() setTabs([{ id: 'discover', label: 'Discover' }]) expect(screen.getByRole('tab', { name: en.installedTab }).getAttribute('aria-selected')).toBe('true') + // The tabs stay in the pinned page head with the title; the tab panel scrolls in the page body. + expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).not.toBeNull() expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull() fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) expect(navigation.getSnapshot().tab).toBe('discover') expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'Discover' }).id) + expect(screen.getByRole('tabpanel').closest('[data-plugin-body]')).not.toBeNull() expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull() expect(screen.queryByRole('button', { name: en.refresh })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Discover install' })) @@ -277,6 +281,22 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() }) + it('keeps the header controls in the scrolling page without remounting them', () => { + const { setTabs, props, hidePage } = renderTab({ packages: [pkg()] }) + const bar = document.querySelector('[data-plugin-bar]') + const body = document.querySelector('[data-plugin-body]')! + const refresh = screen.getByRole('button', { name: en.refresh }) + expect(bar?.parentElement).toBe(body) + expect(props.createHeaderRef).toHaveBeenCalledTimes(1) + setTabs([{ id: 'discover', label: 'Discover' }]) + expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).toBe(bar) + act(() => { body.scrollTop = 160; fireEvent.scroll(body) }) + expect(screen.getByRole('button', { name: en.refresh })).toBe(refresh) + expect(props.createHeaderRef).toHaveBeenCalledTimes(1) + hidePage() + expect(document.querySelector('[data-plugin-bar]')).toBeNull() + }) + it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => { const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, { @@ -502,7 +522,7 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-detail-document]')).toBeNull() // A read the controller forgot, after a profile change, starts again. b.setDetails({}) - expect(b.actions.loadDetail).toHaveBeenCalledTimes(2) + expect(b.actions.loadDetail).toHaveBeenLastCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) }) it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { @@ -740,7 +760,7 @@ describe('PluginManagerPage', () => { expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) }) - it('omits built-in profile dependencies while retaining optional and third-party bundles', () => { + it('omits profile and persistent-terminal bundles while retaining other optional and third-party bundles', () => { renderTab({ packages: [ ...[ @@ -750,6 +770,7 @@ describe('PluginManagerPage', () => { '@deepseek-ai/dsh-sdk-app', '@deepseek-ai/dsh-acp-app', '@deepseek-ai/dsh-sdk-minimal', + '@deepseek-ai/dsh-experimental-terminal-bundle', ].map(name => pkg({ name })), pkg({ name: '@acme/dsh-base', readOnlyReason: 'management-required' }), pkg({ name: 'dsh-better-sidebar' }), @@ -762,9 +783,29 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-count]')).toBeNull() }) - it.each([false, true])('shows an empty list for built-in bundles with errors and installed=%s', (installed) => { + it.each([false, true])('hides persistent terminals from both lists and direct navigation without changing enabled=%s', (enabled) => { + const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const b = renderTab({ packages: [ + pkg({ name, official: true, enabled, rows: [row()] }), + pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', official: true }), + ] }) + expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() + expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'row', name, rowId: 'sidebar' }) }) + expect(document.querySelector('[data-plugin-row-detail]')).toBeNull() + expect(screen.queryByRole('switch', { name: en.enableToggle.replace('{name}', name) })).toBeNull() + expect(b.actions.setEnabled).not.toHaveBeenCalled() + expect(b.actions.setRowEnabled).not.toHaveBeenCalled() + expect(b.actions.uninstall).not.toHaveBeenCalled() + }) + + it.each([false, true])('shows an empty list for hidden bundles with errors and installed=%s', (installed) => { renderTab({ - packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'].map(name => pkg({ + packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-experimental-terminal-bundle'].map(name => pkg({ name, installed, error: { code: 'operation-error', diagnostic: 'Unreadable bundle' }, })), }) @@ -1079,7 +1120,7 @@ describe('PluginManagerPage', () => { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' :
, } - it('keeps configuration and installed plugins on the main list and shows all official plugins on the secondary page', () => { + it('keeps configuration and installed plugins on the main list and shows eligible official plugins on the secondary page', () => { const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' renderTab( { packages: [ @@ -1088,6 +1129,8 @@ describe('PluginManagerPage', () => { pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-session-search', installed: false, optional: true, enabled: false, listedByDefault: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-ralph-bundle', installed: false, optional: true, enabled: false, listedByDefault: false }), pkg({ name: '@acme/new-official', official: true }), pkg(), ] }, @@ -1135,6 +1178,65 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.basicTitle })).toBeTruthy() }) + it.each([ + '@deepseek-ai/dsh-cua-bundle', + '@deepseek-ai/dsh-experimental-session-search', + '@deepseek-ai/dsh-experimental-ralph-bundle', + '@acme/shipped-exploration', + ])('omits %s from default lists while retaining explicit profile selections and installations', (name) => { + const entry = pkg({ name, installed: false, optional: true, removable: false, enabled: false, listedByDefault: false }) + const other = pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', installed: false, optional: true, enabled: false }) + const { actions, navigation, set, setLanguage } = renderTab({ packages: [other, entry] }) + const card = () => document.querySelector(`[data-plugin-package="${name}"]`) + for (const dict of [en, zh]) { + setLanguage(dict) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.moreAction })) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.backToList })) + } + setLanguage(en) + set({ packages: [other, { ...entry, enabled: true }] }) + expect(card()).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', name) })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) + set({ packages: [other, entry] }) + expect(card()).toBeNull() + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + expect(document.querySelector('[data-plugin-group="more"]')).toBeNull() + act(() => { navigation.actions.setView({ kind: 'more' }) }) + set({ packages: [other, { ...entry, installed: true, availability: 'profile' }] }) + expect(card()).not.toBeNull() + }) + + it.each([en, zh])('hides unsupported CUA by default and retains installed or selected entries for management', (dict) => { + const name = '@deepseek-ai/dsh-cua-bundle' + const entry = pkg({ name, installed: false, enabled: false, optional: true, listedByDefault: false, + error: { code: 'unsupported-platform' } }) + const other = pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', installed: false, optional: true, enabled: false }) + const { actions, set, setLanguage } = renderTab({ packages: [other, entry] }) + setLanguage(dict) + const card = () => document.querySelector(`[data-plugin-package="${name}"]`) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.moreAction })) + expect(card()).toBeNull() + for (const availability of ['profile', 'missing'] as const) { + const installed = { ...entry, installed: true, availability, installTarget: { spec: `${name}@1.0.0`, version: '1.0.0' } } + set({ packages: [other, installed] }) + expect(card()).not.toBeNull() + expect(screen.getByText(dict.reasonUnsupportedPlatform)).toBeTruthy() + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) })).toHaveProperty('disabled', true) + } + set({ packages: [other, { ...entry, enabled: true }] }) + fireEvent.click(screen.getByRole('button', { name: dict.openDetail.replace('{name}', name) })) + const toggle = screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) }) + expect(toggle).toHaveProperty('disabled', false) + expect(screen.getByText(`${dict.reasonLabel}: ${dict.reasonUnsupportedPlatform}`)).toBeTruthy() + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) + }) + it.each([en, zh])('keeps disabled CUA on the secondary page with experimental labels', (dict) => { const name = '@deepseek-ai/dsh-cua-bundle' const entry = (enabled: boolean) => pkg({ name, official: true, enabled, meta: { title: 'Computer Use' } }) @@ -1163,8 +1265,10 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeTruthy() }) - it('returns from package details to the secondary page and retains it across React remounts', () => { - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + it.each([ + '@deepseek-ai/dsh-experimental-session-titles-bundle', + '@deepseek-ai/dsh-personalized-recommendations', + ])('returns %s details to the secondary page and retains it across React remounts', (name) => { const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() @@ -1179,9 +1283,11 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() }) - it('pins a bundle the profile picks onto the main list, in its full-list place', () => { + it.each([ + '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-personalized-recommendations', + ])('pins enabled %s onto the main list and removes it when disabled', (first) => { const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' - const first = '@deepseek-ai/dsh-experimental-tool-worktree' const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) @@ -1207,7 +1313,7 @@ describe('PluginManagerPage', () => { }) it('returns a held-back bundle opened outside the lists to the list that shows it', () => { - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) expect(document.querySelector(`[data-plugin-detail="${name}"]`)).toBeTruthy() @@ -1435,6 +1541,11 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed} ${en.reasonIncompatibleInstalled}`)).toBeTruthy() fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) expect(document.querySelector('[data-plugin-detail]')).toBeNull() + set({ packages: [pkg({ enabled: false, error: { code: 'unsupported-platform' } })] }) + expect(screen.getByText(en.reasonUnsupportedPlatform)).toBeTruthy() + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) + set({ packages: [pkg({ enabled: true, error: { code: 'unsupported-platform' } })] }) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', false) // A bundle that leaves the list drops back to the cards. const { version: _version, ...unversioned } = pkg() set({ packages: [unversioned] }) @@ -1750,6 +1861,21 @@ describe('PluginManagerPage', () => { expect(actions.closeInstall).toHaveBeenCalledTimes(1) }) + it.each([en, zh])('offers an existing bundled plugin without claiming a download or installation', (dict) => { + const name = '@acme/shipped-exploration' + const { actions, setLanguage } = renderTab({ install: { + ...IDLE_INSTALL, open: true, spec: name, phase: 'done', installed: name, installedVersion: '1.0.0', + subject: { status: 'accepted', spec: name, name, kind: 'registry', registry: null, bundle: true, bundled: true, version: '1.0.0' }, + } }) + setLanguage(dict) + expect(screen.getByText(dict.installBundledTitle)).toBeTruthy() + expect(screen.getByText(dict.installBundledDescription)).toBeTruthy() + expect(screen.queryByText(dict.installedTitle)).toBeNull() + expect(screen.queryByRole('button', { name: dict.installDetailsShow })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.installEnableNow })) + expect(actions.enableInstalled).toHaveBeenCalledExactlyOnceWith() + }) + it('asks to allow the scripts a blocked install left pending, retries with them, and says what was allowed', () => { const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true, registry: null } as const const { actions, set } = renderTab({ @@ -2156,7 +2282,7 @@ describe('PluginManagerPage', () => { const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) try { - const { actions, set } = renderTab({ packages: [pkg(), pkg({ name: '@deepseek-ai/dsh-experimental-terminal-bundle', official: true })] }) + const { actions, set } = renderTab({ packages: [pkg(), pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', official: true })] }) // A package the list does not show has nothing to scroll to; the mark still times out. set({ highlight: 'missing' }) expect(scrollIntoView).not.toHaveBeenCalled() @@ -2179,7 +2305,7 @@ describe('PluginManagerPage', () => { it('highlights only the visible list and does not extend the mark when navigating or refreshing', () => { vi.useFakeTimers() - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' const scrollIntoView = vi.fn() const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) @@ -2345,8 +2471,32 @@ describe('PluginManagerPage', () => { }) }) +it.each([false, true])('shows progress only for the changing bundle and clears it when settled (enabled=%s)', (enabled) => { + const name = 'dsh-better-sidebar' + const { set, setLanguage, actions } = renderTab({ packages: [pkg({ enabled }), pkg({ name: 'dsh-other' })] }) + const toggle = screen.getByRole('switch', { name: `Enable ${name}` }) + const status = within(toggle.parentElement!).getByRole('status') + expect(status.textContent).toBe('') + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, !enabled) + set({ busy: [name] }) + for (const dict of [en, zh]) { + setLanguage(dict) + expect(status.textContent).toBe(dict.applyingChange.replace('{name}', name)) + expect(status.querySelector('[data-state="ongoing"]')).not.toBeNull() + } + expect(toggle).toHaveProperty('disabled', true) + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledOnce() + expect(screen.getByRole('switch', { name: '启用 dsh-other' })).toHaveProperty('disabled', false) + set({ busy: [] }) + expect(within(toggle.parentElement!).getByRole('status')).toBe(status) + expect(status.textContent).toBe('') + expect(toggle).toHaveProperty('disabled', false) +}) + it.each([false, true])('opens activation guidance details and returns to the originating list (experimental: %s)', (fromMore) => { - const name = fromMore ? '@deepseek-ai/dsh-experimental-terminal-bundle' : 'dsh-better-sidebar' + const name = fromMore ? '@deepseek-ai/dsh-experimental-session-titles-bundle' : 'dsh-better-sidebar' const bundle = pkg({ name, official: fromMore }) const { set, actions } = renderTab({ packages: [{ ...bundle, enabled: false }] }, { bundles: new Set([name]) }, { [`plugins.bundle.activation:${name}`]: (_view, owner) => , @@ -2527,6 +2677,21 @@ describe('on-demand Official bundles', () => { }) }) +it('keeps bundle configuration mounted while its operation is pending', () => { + const name = 'dsh-better-sidebar' + const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, { + [`plugins.bundle.config:${name}`]: () =>
Configuration
, + }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) + const panel = screen.getByTestId('bundle-config') + expect(panel.textContent).toBe('Configuration') + set({ busy: [name] }) + expect(screen.getByTestId('bundle-config')).toBe(panel) + expect(panel.textContent).toBe('Configuration') + set({ busy: [] }) + expect(panel.textContent).toBe('Configuration') +}) + describe('registry versions and updates', () => { const MIRROR = 'https://registry.npmmirror.com/' const updatable = (): PackageView => pkg({ updatable: true, version: '0.16.0', source: 'dsh-better-sidebar@0.16.0' }) diff --git a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts index b57cee89f6..ea53f6b804 100644 --- a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts @@ -54,7 +54,7 @@ it('reports failures by reason, reads again on retry, and settles a retried key expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) -it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => { +it('invalidates reads on reset, drops superseded answers, and stops publishing after disposal', async () => { const { controller, manager, state } = setup() let answer: (value: Answer) => void = () => {} manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) @@ -80,7 +80,7 @@ it('forgets every read on reset, drops answers of forgotten reads, and stops pub controller.dispose() answer({ ok: true, value: FOUND }) await flush() - expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) }) it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => { @@ -123,3 +123,22 @@ it('addresses relative images through the Host route and leaves other destinatio expect(controller.image('x', 'README.md', destination, false)).toBeUndefined() } }) + + +it('retains a displayed document during invalidation and replaces it after one fresh read', async () => { + const { controller, manager, state } = setup() + controller.load('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + const response = Promise.withResolvers>() + onTestFinished(() => { response.resolve({ ok: true, value: FOUND }) }) + manager.readBundleDetail.mockImplementationOnce(() => response.promise) + controller.reset() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) + controller.load('a', '1.0.0', 'en', false) + controller.load('a', '1.0.0', 'en', false) + expect(manager.readBundleDetail).toHaveBeenCalledTimes(2) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) + const replacement = { ...FOUND, markdown: '# Updated' } + response.resolve({ ok: true, value: replacement }) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: replacement }) }) +}) diff --git a/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts new file mode 100644 index 0000000000..432dfdcb44 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +/** Deterministic layout endpoints exercise scroll geometry without jsdom's absent layout engine. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { createPluginHeaderRef } from '../src/client/header-scroll.ts' + +type PartName = 'title' | 'intro' | 'toolbar' | 'tabs' +interface Box { x: number; y: number; width: number; height: number } +interface Layout { + width: number + resting: { height: number; parts: Partial> } + compact: { height: number; parts: Partial> } +} + +function box(x: number, y: number, width: number, height: number): Box { + return { x, y, width, height } +} + +function wide(tabs = true): Layout { + return { + width: 960, + resting: { + height: tabs ? 140 : 90, + parts: { + title: box(0, 28, 120, 28), intro: box(0, 60, 320, 20), toolbar: box(600, 28, 220, 32), + ...tabs ? { tabs: box(0, 100, 240, 30) } : {}, + }, + }, + compact: { + height: 64, + parts: { + title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), toolbar: box(600, 16, 220, 32), + ...tabs ? { tabs: box(0, 16, 180, 30) } : {}, + }, + }, + } +} + +function narrow(): Layout { + return { + width: 320, + resting: { + height: 210, + parts: { + title: box(0, 28, 120, 28), intro: box(0, 60, 280, 20), + toolbar: box(0, 90, 280, 32), tabs: box(0, 160, 280, 30), + }, + }, + compact: { + height: 70, + parts: { + title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), + toolbar: box(200, 16, 80, 32), tabs: box(0, 16, 160, 30), + }, + }, + } +} + +function harness() { + const fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') + const fonts = new EventTarget() + const releases: (() => void)[] = [] + onTestFinished(() => { + for (const release of releases.reverse()) release() + if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') + else Object.defineProperty(document, 'fonts', fontsDescriptor) + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) + + const resizes: ControlledResize[] = [] + const mutations: ControlledMutation[] = [] + class ControlledResize implements ResizeObserver { + observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) } + deliver(): void { this.callback([], this) } + } + class ControlledMutation implements MutationObserver { + observe = vi.fn<(target: Node, options?: MutationObserverInit) => void>() + disconnect = vi.fn<() => void>() + takeRecords(): MutationRecord[] { return [] } + constructor(private readonly callback: MutationCallback) { mutations.push(this) } + deliver(): void { this.callback([], this) } + } + vi.stubGlobal('ResizeObserver', ControlledResize) + vi.stubGlobal('MutationObserver', ControlledMutation) + + function fixture(initial: Layout = wide(), ref = createPluginHeaderRef()) { + const body = document.createElement('div') + const bar = document.createElement('div') + const header = document.createElement('header') + const content = document.createElement('div') + const index = releases.length + const elements = new Map() + for (const name of Object.keys(initial.resting.parts) as PartName[]) { + const part = document.createElement('div') + part.dataset.pluginHeaderPart = name + part.id = `header-${index}-${name}` + if (name === 'toolbar' || name === 'intro') { + const button = document.createElement('button') + button.id = `header-${index}-${name}-button` + button.textContent = name + part.append(button) + } else part.textContent = name + header.append(part) + elements.set(name, part) + } + bar.append(header) + content.textContent = 'Plugin cards' + body.append(bar, content) + document.body.append(body) + releases.push(() => { ref(null); body.remove() }) + const state = { layout: initial } + const clones: HTMLDivElement[] = [] + const measurements: { + compact: boolean + inert: boolean + ariaHidden: string | null + ids: number + styles: number + natural: boolean + scrolled: boolean + connected: boolean + width: string + }[] = [] + vi.spyOn(bar, 'getBoundingClientRect').mockImplementation(() => new DOMRect(50, 100, state.layout.width, state.layout.resting.height)) + const cloneNode = bar.cloneNode.bind(bar) + vi.spyOn(bar, 'cloneNode').mockImplementation((deep) => { + const clone = cloneNode(deep) as HTMLDivElement + clones.push(clone) + const cloneHeader = clone.querySelector('header')! + // A different viewport origin ensures only header-relative coordinates drive the live controls. + vi.spyOn(cloneHeader, 'getBoundingClientRect').mockImplementation(() => { + measurements.push({ + compact: clone.hasAttribute('data-compact'), inert: clone.inert, + ariaHidden: clone.getAttribute('aria-hidden'), ids: clone.querySelectorAll('[id]').length, + styles: clone.querySelectorAll('[data-plugin-header-part][style]').length, + natural: clone.hasAttribute('data-natural'), scrolled: clone.hasAttribute('data-scrolled'), + connected: clone.isConnected, width: clone.style.width, + }) + return new DOMRect(180, 240, state.layout.width, 0) + }) + vi.spyOn(clone, 'getBoundingClientRect').mockImplementation(() => + new DOMRect(180, 240, state.layout.width, endpoint().height)) + function endpoint() { return clone.hasAttribute('data-compact') ? state.layout.compact : state.layout.resting } + for (const name of elements.keys()) { + const part = clone.querySelector(`[data-plugin-header-part="${name}"]`)! + vi.spyOn(part, 'getBoundingClientRect').mockImplementation(() => { + const bounds = endpoint().parts[name]! + return new DOMRect(180 + bounds.x, 240 + bounds.y, bounds.width, bounds.height) + }) + } + return clone + }) + function part(name: PartName): HTMLDivElement { + const element = elements.get(name) + if (element === undefined) throw new Error(`Fixture has no ${name}`) + return element + } + function scroll(top: number): void { + body.scrollTop = top + body.dispatchEvent(new Event('scroll')) + } + return { body, bar, header, content, state, clones, measurements, part, scroll, ref, attach: () => { ref(bar) } } + } + return { fonts, resizes, mutations, fixture } +} + +describe('plugin header scroll geometry', () => { + it.each([0, 12, 600])('clamps each trajectory and reverses without using a %ipx scroll range', (range) => { + const h = harness(), f = h.fixture() + Object.defineProperties(f.body, { clientHeight: { value: 500 }, scrollHeight: { value: 500 + range } }) + f.attach() + expect(f.bar.style.getPropertyValue('--plugin-header-travel')).toBe('84px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px') + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px') + f.scroll(6) + expect(f.part('toolbar').style.translate).toBe('0px -6px') + expect(f.part('tabs').style.translate).toBe('0px -6px') + expect(f.bar.hasAttribute('data-compact')).toBe(false) + expect(f.bar.hasAttribute('data-scrolled')).toBe(true) + f.scroll(20) + expect(f.part('toolbar').style.translate).toBe('0px -12px') + expect(f.part('tabs').style.translate).toBe('0px -20px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('120px') + f.scroll(83) + expect(f.bar.hasAttribute('data-compact')).toBe(false) + f.scroll(84) + expect(f.bar.hasAttribute('data-compact')).toBe(true) + expect(f.part('title').style.translate).toBe('0px -56px') + expect(f.part('intro').style.translate).toBe('0px -80px') + expect(f.part('tabs').style.translate).toBe('0px -84px') + f.scroll(1_000) + expect(f.part('tabs').style.translate).toBe('0px -84px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('64px') + f.scroll(6) + expect(f.part('toolbar').style.translate).toBe('0px -6px') + expect(f.part('intro').inert).toBe(false) + expect(f.bar.hasAttribute('data-compact')).toBe(false) + f.scroll(-10) + expect(f.part('tabs').style.translate).toBe('0px 0px') + expect(f.bar.hasAttribute('data-scrolled')).toBe(false) + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('140px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px') + expect(f.body.scrollHeight - f.body.clientHeight).toBe(range) + expect(f.content.style.cssText).toBe('') + }) + + it('accounts for a layout scrollbar without retaining its sizing property after detachment', () => { + const h = harness(), f = h.fixture() + Object.defineProperties(f.body, { offsetWidth: { value: 960 }, clientWidth: { value: 955 } }) + f.attach() + expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('5px') + f.ref(null) + expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('') + }) + + it('interpolates narrow wrapped controls to their measured horizontal and width endpoints', () => { + const h = harness(), f = h.fixture(narrow()) + f.attach() + expect(f.part('toolbar').style.getPropertyValue('--plugin-part-y')).toBe('90px') + expect(f.part('toolbar').style.getPropertyValue('--plugin-part-width')).toBe('280px') + f.scroll(37) + expect(f.part('toolbar').style.translate).toBe('100px -37px') + expect(f.part('toolbar').style.width).toBe('180px') + f.scroll(144) + expect(f.part('toolbar').style.translate).toBe('200px -74px') + expect(f.part('toolbar').style.width).toBe('80px') + expect(f.part('tabs').style.translate).toBe('0px -144px') + expect(f.part('tabs').style.width).toBe('160px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('70px') + f.scroll(0) + expect(f.part('toolbar').style.translate).toBe('0px 0px') + expect(f.part('toolbar').style.width).toBe('280px') + expect(f.part('tabs').style.width).toBe('280px') + }) + + it('clips the no-tabs intro beneath the pinned title and restores interaction when scrolling back', () => { + const h = harness(), f = h.fixture(wide(false)) + f.attach() + f.scroll(16) + expect(f.part('title').style.translate).toBe('0px -12px') + expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)') + expect(f.part('intro').inert).toBe(false) + f.scroll(26) + expect(f.part('intro').style.translate).toBe('0px -26px') + expect(f.part('intro').style.clipPath).toBe('inset(50% 0 0)') + expect(f.part('intro').inert).toBe(false) + f.scroll(36) + expect(f.part('intro').style.clipPath).toBe('inset(100% 0 0)') + expect(f.part('intro').inert).toBe(true) + expect(f.bar.hasAttribute('data-compact')).toBe(true) + f.scroll(0) + expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)') + expect(f.part('intro').inert).toBe(false) + }) + + it('scrolls the tabs intro fully out without the no-tabs clipping treatment', () => { + const h = harness(), f = h.fixture() + f.part('intro').style.clipPath = 'inset(100% 0 0)' + f.attach() + f.scroll(79) + expect(f.part('intro').style.clipPath).toBe('') + expect(f.part('intro').inert).toBe(false) + f.scroll(80) + expect(f.part('intro').style.translate).toBe('0px -80px') + expect(f.part('intro').inert).toBe(true) + f.scroll(40) + expect(f.part('intro').inert).toBe(false) + expect(f.part('intro').style.translate).toBe('0px -40px') + }) + + it.each(['resize', 'mutation', 'font'] as const)('remeasures changed endpoints on %s without replacing focused controls', (event) => { + const h = harness(), f = h.fixture() + const button = f.part('toolbar').querySelector('button')! + const clicked = vi.fn() + button.addEventListener('click', clicked) + f.attach() + f.scroll(144) + button.focus() + f.state.layout = narrow() + if (event === 'resize') h.resizes[0]!.deliver() + else if (event === 'mutation') h.mutations[0]!.deliver() + else h.fonts.dispatchEvent(new Event('loadingdone')) + expect(f.part('toolbar').style.translate).toBe('200px -74px') + expect(f.part('toolbar').style.width).toBe('80px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('210px') + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px') + expect(document.activeElement).toBe(button) + expect(f.part('toolbar').querySelector('button')).toBe(button) + button.click() + expect(clicked).toHaveBeenCalledTimes(1) + expect(f.clones).toHaveLength(2) + expect(f.clones.every(clone => !clone.isConnected)).toBe(true) + expect(f.body.querySelector('[data-measuring]')).toBeNull() + expect(f.measurements.slice(-2)).toEqual([false, true].map(compact => ({ + compact, inert: true, ariaHidden: 'true', ids: 0, styles: 0, + natural: false, scrolled: false, connected: true, width: '320px', + }))) + const ids = [...f.body.querySelectorAll('[id]')].map(element => element.id) + expect(new Set(ids).size).toBe(ids.length) + expect(h.resizes[0]!.observe).toHaveBeenCalledWith(f.body) + expect(h.mutations[0]!.observe).toHaveBeenCalledWith(f.header, { + childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'], + }) + }) + + it('removes the inert measurement copy when a compact layout read fails', () => { + const h = harness(), f = h.fixture() + const failure = new Error('Layout read interrupted') + const resting = f.state.layout.resting + Object.defineProperty(f.state, 'layout', { + get: () => ({ + width: 960, + resting, + get compact(): Layout['compact'] { throw failure }, + }), + }) + expect(() => { f.attach() }).toThrow(failure) + expect(f.clones).toHaveLength(1) + expect(f.clones[0]!.isConnected).toBe(false) + expect(f.body.querySelector('[data-measuring]')).toBeNull() + expect(h.resizes).toHaveLength(0) + expect(h.mutations).toHaveLength(0) + }) + + it('disconnects observers and listeners and ignores callbacks already queued before detachment', () => { + const h = harness(), f = h.fixture() + f.attach() + f.scroll(20) + const before = f.bar.outerHTML + f.ref(null) + expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1) + expect(h.mutations[0]!.disconnect).toHaveBeenCalledTimes(1) + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('') + f.state.layout = narrow() + f.scroll(144) + h.resizes[0]!.deliver() + h.mutations[0]!.deliver() + h.fonts.dispatchEvent(new Event('loadingdone')) + expect(f.bar.outerHTML).toBe(before) + expect(f.clones).toHaveLength(1) + f.ref(null) + expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1) + }) + + it('rebinds one ref without allowing the old element or its callbacks to change the replacement', () => { + const h = harness(), ref = createPluginHeaderRef(), first = h.fixture(wide(), ref), second = h.fixture(narrow(), ref) + first.attach() + first.scroll(20) + const oldMarkup = first.bar.outerHTML + second.attach() + second.scroll(37) + const newMarkup = second.bar.outerHTML + first.scroll(144) + h.resizes[0]!.deliver() + h.mutations[0]!.deliver() + expect(first.bar.outerHTML).toBe(oldMarkup) + expect(second.bar.outerHTML).toBe(newMarkup) + expect(first.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('') + expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px') + expect(first.clones).toHaveLength(1) + expect(second.clones).toHaveLength(1) + }) + + it('keeps two mounted refs independent when scrolling, remeasuring, and disposing either instance', () => { + const h = harness(), first = h.fixture(), second = h.fixture(narrow()) + first.attach() + second.attach() + first.scroll(20) + expect(second.part('toolbar').style.translate).toBe('0px 0px') + second.scroll(37) + expect(first.part('toolbar').style.translate).toBe('0px -12px') + expect(second.part('toolbar').style.translate).toBe('100px -37px') + first.ref(null) + const oldMarkup = first.bar.outerHTML + second.state.layout = wide() + h.fonts.dispatchEvent(new Event('loadingdone')) + expect(first.bar.outerHTML).toBe(oldMarkup) + expect(first.clones).toHaveLength(1) + expect(second.clones).toHaveLength(2) + expect(second.part('toolbar').style.translate).toBe('0px -12px') + expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px') + expect(h.resizes[1]!.disconnect).not.toHaveBeenCalled() + second.scroll(0) + expect(second.part('toolbar').style.translate).toBe('0px 0px') + }) +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 6b8f2e2968..299cf77791 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -702,6 +702,81 @@ describe('PluginManagerController', () => { await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) }) }) + it('offers a default-hidden shipped bundle by name, permits cancellation, and enables without installing', async () => { + let enabled = false + const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true, + availability: 'installation', optional: true, removable: false, listedByDefault: false } + const { plugins, face, state, controller, track } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([{ ...shipped, enabled }]))), + setBundleEnabled: vi.fn(() => { enabled = true; return Promise.resolve(ok(APPLIED)) }), + }) + await controller.load() + expect(state().packages[0]?.listedByDefault).toBe(false) + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install).toMatchObject({ phase: 'done', installed: shipped.name, subject: { bundled: true }, runs: [] }) + expect(plugins.setBundleEnabled).not.toHaveBeenCalled() + face.closeInstall() + expect(enabled).toBe(false) + expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'user_cancelled' })) + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + face.enableInstalled() + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(shipped.name, true) + expect(state().packages[0]?.enabled).toBe(true) + expect(state().highlight).toBe(shipped.name) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.installBundle).not.toHaveBeenCalled() + expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: shipped.name })) + // The same name cannot start another activation after it is enabled. + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install.inputError?.problem).toBe('shipped') + }) + + it('reports a refused bundled activation without downloading a replacement', async () => { + const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true, + availability: 'installation', optional: true, removable: false, listedByDefault: false } + const { plugins, face, state, controller } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([shipped]))), + setBundleEnabled: vi.fn(() => Promise.resolve(ok(failed({ code: 'operation-error', diagnostic: 'activation refused' })))), + }) + await controller.load() + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', packageName: shipped.name, reason: 'activation refused' }) + expect(state().packages[0]?.enabled).toBe(false) + expect(plugins.installBundle).not.toHaveBeenCalled() + }) + + it('accepts a bundled package without optional version or description and reports a lost activation reply', async () => { + const shipped: BundleInfo = { name: '@acme/shipped-exploration', installed: false, official: true, enabled: false, + availability: 'installation', optional: true, removable: false, updatable: false, listedByDefault: false, rows: [], overrides: [] } + const { plugins, face, state, controller } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([shipped]))), + setBundleEnabled: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))), + }) + await controller.load() + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install.installedVersion).toBeNull() + expect(state().install.subject).not.toHaveProperty('version') + expect(state().install.subject).not.toHaveProperty('description') + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', reason: 'offline' }) + expect(plugins.installBundle).not.toHaveBeenCalled() + }) + it('leaves the check or the failed screen for the spec at once', async () => { const inspectGate = deferred>>() const { plugins, face, state, controller } = bench({ @@ -2204,3 +2279,33 @@ describe('version checks and updates', () => { expect(state().update?.phase).toBe('running') }) }) + + +it.each(['bundle', 'row'] as const)('publishes %s activity before dispatch and settles it without a mounted page', async (kind) => { + const f = bench() + await f.controller.load() + const activity = f.controller.observeChanges(BUNDLE.name) + const other = f.controller.observeChanges('another-bundle') + const response = Promise.withResolvers>>() + onTestFinished(() => { response.resolve(ok(APPLIED)) }) + const remote = kind === 'bundle' ? f.plugins.setBundleEnabled : f.plugins.setPluginEnabled + remote.mockImplementationOnce(() => { + expect(activity.getSnapshot()).toBe(true) + expect(other.getSnapshot()).toBe(false) + return response.promise + }) + const observations: boolean[] = [] + const unsubscribe = activity.subscribe(() => { observations.push(activity.getSnapshot()) }) + onTestFinished(unsubscribe) + expect(activity.getSnapshot()).toBe(false) + if (kind === 'bundle') f.face.setEnabled(BUNDLE.name, true) + else f.face.setRowEnabled(ROW_ENTRY, false) + expect(observations).toContain(true) + response.resolve(ok(APPLIED)) + await vi.waitFor(() => { expect(activity.getSnapshot()).toBe(false) }) + expect(observations.at(-1)).toBe(false) + unsubscribe() + const count = observations.length + await f.controller.load() + expect(observations).toHaveLength(count) +}) diff --git a/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts b/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts new file mode 100644 index 0000000000..79cd0229f9 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts @@ -0,0 +1,104 @@ +/** + * Plugin page touch rules, asserted against the CSS text on disk. + * + * jsdom resolves neither `pointer` nor `hover` media features, so these gates read + * the sheet instead: a `:hover` rule outside `@media (hover: hover)` latches onto a + * tap and strands the control in its pointer-only state, and a text field under + * 16px makes iOS Safari zoom the visual viewport when it takes focus. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/PluginManagerPage.module.css', import.meta.url)), 'utf8') +/** Comment bodies blanked, so prose about `:hover` is not read as a rule. */ +const rules = css.replace(/\/\*[\s\S]*?\*\//g, text => ' '.repeat(text.length)) + +/** The line holding `index`, for failure messages. */ +function lineAt(source: string, index: number): string { + const start = source.lastIndexOf('\n', index) + 1 + const end = source.indexOf('\n', index) + return source.slice(start, end === -1 ? undefined : end).trim() +} + +/** Every state selector with the media query it sits in, read from the sheet's own blocks. */ +function statesIn(source: string, pattern: RegExp): { line: string; hoverMedia: boolean }[] { + const stack: (string | null)[] = [] + const found: { line: string; hoverMedia: boolean }[] = [] + for (const token of source.matchAll(/@media[^{]*\{|:hover|:focus-visible|\{|\}/g)) { + const text = token[0] + if (text === '}') { + stack.pop() + continue + } + if (text === '{') { + stack.push(null) + continue + } + if (text.startsWith('@media')) { + // The matched text includes the block's opening brace, which the frame stands for. + stack.push(text.slice(0, -1)) + continue + } + if (!pattern.test(text)) continue + const index = token.index ?? 0 + found.push({ + line: lineAt(source, index), + hoverMedia: stack.some(frame => frame !== null && /hover:\s*hover/.test(frame)), + }) + } + return found +} + +/** The declarations of one rule, by its literal selector. */ +function ruleBody(selector: string): string { + const start = rules.indexOf(`${selector} {`) + if (start === -1) throw new Error(`PluginManagerPage.module.css has no \`${selector}\` rule`) + const open = rules.indexOf('{', start) + return rules.slice(open + 1, rules.indexOf('}', open)) +} + +describe('plugin page touch rules', () => { + it('fences every hover affordance behind a hover-capable pointer', () => { + // Unguarded, the browser keeps `:hover` on the tapped element until the next tap + // elsewhere: a card or menu row stays painted as if the pointer were still on it. + // Each affordance must still exist, so an emptied sheet cannot satisfy the gate. + const unguarded = statesIn(rules, /^:hover$/).filter(state => !state.hoverMedia) + expect(unguarded.map(state => state.line)).toEqual([]) + for (const selector of [ + '.groupMore:hover', '.cardLink:hover', '.iconButton:hover', '.deleteButton:hover', '.guideToggle:hover', + '.registryToggle:hover', '.registryOption:hover', '.wizardClose:hover', '.crumb:hover', '.rowOpen:hover', + ]) { + expect(rules).toContain(selector) + } + }) + + it('keeps focus rings outside the hover fence', () => { + // A tablet with a keyboard reports no hover; fencing `:focus-visible` with the + // hover rules would leave keyboard use without a visible focus ring. + const fenced = statesIn(rules, /^:focus-visible$/).filter(state => state.hoverMedia) + expect(fenced.map(state => state.line)).toEqual([]) + }) + + it('gives touch clients a press state where the hover state is fenced off', () => { + // Touch-down feedback is the only cue left once hover is fenced: without it a + // card reads as inert until its detail page appears. + expect(ruleBody('.cardLink:active')).toContain('background: var(--dsw-alias-interactive-bg-active)') + expect(ruleBody('.iconButton:active:not(:disabled)')).toContain('background: var(--dsw-alias-interactive-bg-active)') + expect(ruleBody('.deleteButton:active:not(:disabled)')) + .toContain('background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover))') + }) + + it('keeps focused text fields at 16px under a coarse pointer', () => { + // iOS Safari zooms the visual viewport for a focused field under 16px and leaves + // the page pannable afterwards; the install dialog, the registry address, and the + // component filter are this page's three text fields. + const coarse = /@media \(pointer: coarse\) \{([\s\S]*?)\n\}/.exec(rules)?.[1] ?? '' + const rule = /([^{}]+)\{([^{}]*)\}/.exec(coarse) + expect(rule).not.toBeNull() + for (const selector of [".installField input[type='text']", '.registryCustomField', '.partsFilter input']) { + expect(rule?.[1]).toContain(selector) + } + expect(rule?.[2]).toContain('font-size: 16px') + }) +}) diff --git a/packages/client/ui-plugin-manager/tsconfig.client.json b/packages/client/ui-plugin-manager/tsconfig.client.json index 5f8c1f5e42..eefbe67b00 100644 --- a/packages/client/ui-plugin-manager/tsconfig.client.json +++ b/packages/client/ui-plugin-manager/tsconfig.client.json @@ -9,6 +9,9 @@ { "path": "../../util/package-manifest" }, + { + "path": "../../util/official-bundles" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index 4ca8e13747..1fa39181d9 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-market/README.md /: - en: af3fe4b70bddbc1b - zh: ac1c4326165266d9 + en: b77d6d729562a88f + zh: b1634cc1d027424c /deepseek-ai-dsh-client-ui-plugin-market: en: 62972eb0dff8c145 zh: 682cc03b492cd730 /deepseek-ai-dsh-client-ui-plugin-market/summary: - en: 55a3d1b12cd7be61 - zh: a961cf0212b3fda8 + en: 71328d505b984fe5 + zh: 9281e7dbaa01ef9a /deepseek-ai-dsh-client-ui-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-plugin-market/use-this-package: - en: f873bf36195df821 - zh: e13c78ba3f3e29f1 + en: 868b100459e30aad + zh: 8a3a01199dc3c14c /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: - en: 4107a6967934f6b6 - zh: 46acec8bedb86329 + en: e1a480caaff8efea + zh: 0b725fa5bc929f22 /deepseek-ai-dsh-client-ui-plugin-market/model-experience: en: 08d25ab9cbdd7567 zh: 26e6e75c123951ee diff --git a/packages/client/ui-plugin-market/README.md b/packages/client/ui-plugin-market/README.md index 7a2bf9ffa8..283992a8c6 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -1,5 +1,5 @@ --- -description: "Discover tab of the Web Plugins page: search configured plugin market indexes, read an entry, and install it." +description: "Discover tab of the Web Plugins page: search configured plugin indexes, read an entry, and install it." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. +Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin indexes offer; users manage index sources, switch between indexes, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. ## Table of Contents @@ -24,11 +24,11 @@ Discover is the second tab of the **Plugins** page, after **Installed**. It list ## Use this package -Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by market title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected market. +Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by index title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected index. -The toolbar holds the market menu, which switches markets and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the market declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the market declares categories. +The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the index declares categories. -Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. +Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and index title. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. **Install** chooses the default version and opens a confirmation dialog with the entry's icon, title, that version, the author, its source facts, an exemption notice when one applies, and a trust notice. Confirming opens the Plugins install dialog at its check screen with the entry's exact spec and registry; there is no spec form, and a refusal offers **Retry**. The registry an entry names applies to that install only. @@ -36,7 +36,7 @@ The Host [`pluginMarket.search`](../../boot/plugin-market/README.md#use-this-pac The description section shows the default version's long description in the active UI language: the version's locale `meta.detail` file, otherwise its package README, otherwise its repository README, in the [order `pluginMarket` documents](../../boot/plugin-market/README.md#plugin-detail-documents). While the document loads, the section shows three skeleton lines; an entry without a document shows no section; a failed read names its reason, such as a size limit, an unreachable source, or a refused repository, with **Retry**. Raw HTML is omitted, and a document taller than the section's height limit scrolls inside it. Relative image paths load from the Host beside the document, absolute HTTP(S) images load directly without a referrer, and relative links stay text. The section is the shared [`DetailDocument`](../ui-primitives/README.md#component-catalog) primitive. -Reading a market shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the market's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. +Reading an index shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the index's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. ----- @@ -45,7 +45,7 @@ Reading a market shows card skeletons. A failed read shows a short sentence with Detail reads are kept in a second snapshot store keyed by index URL, entry, version, and language; a section starts its read when it renders, an npm version's read first asks the Host's registry comparison and passes `npmFileSources: ['npmmirror']` when npmmirror answered fastest, only the latest read of a key settles it, **Retry** asks the Host to read again instead of returning its cached result, and a connection reset forgets every read, because a restarted Host no longer knows the document ids its images use, so each shown section reads its document again. Image URLs resolve `api/plugin-market/detail-file` against `document.baseURI`, so they keep a deployment prefix and use the Desktop `dsh-app://app/` origin, which [`MarkdownText`](../ui-primitives/README.md) accepts for that route. -The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another market starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. +The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another index starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. ----- diff --git a/packages/client/ui-plugin-market/README.zh.md b/packages/client/ui-plugin-market/README.zh.md index 40b98ec314..276c1a5556 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 插件页的「发现」标签页:搜索已配置的插件市场索引,查看条目并安装。" +description: "Web 插件页的「发现」标签页:搜索已配置的插件索引,查看条目并安装。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 +「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件索引提供的组合包(bundle);用户可以管理索引源、在插件索引之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 ## 目录 @@ -24,11 +24,11 @@ kind: "package-reference" ## 使用本包 -打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件市场标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件市场。 +打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件索引标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件索引。 -工具栏包含:插件市场菜单,用于切换插件市场,最后一项为**管理索引源…**;搜索框;插件市场声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件市场声明了分类时,其后显示分类按钮,**全部**排在第一位。 +工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。 -每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 +每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 **安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。 @@ -36,7 +36,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- 详细介绍部分以当前界面语言显示默认版本的详细介绍:按 [`pluginMarket` 说明的顺序](../../boot/plugin-market/README.zh.md#plugin-detail-documents),依次为该版本 locale 中 `meta.detail` 指定的文件、其包 README、其仓库 README。文档加载期间,该部分显示三行骨架屏;没有文档的条目不显示该部分;读取失败时说明原因,例如超过大小限制、来源无法访问或仓库拒绝访问,并提供**重试**。原始 HTML 会被省略;文档高度超过该部分的高度上限时,在该部分内滚动。相对路径的图片从 Host 上该文档旁的位置加载,绝对 HTTP(S) 地址的图片不带 referrer 直接加载,相对链接保持为文本。该部分使用共享的 [`DetailDocument`](../ui-primitives/README.zh.md#component-catalog) 原语。 -读取插件市场时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件市场上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 +读取插件索引时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件索引上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 ----- @@ -45,7 +45,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- 详细介绍的读取结果保存在第二个快照存储中,以索引 URL、条目、版本和语言为键;该部分在渲染时开始读取;npm 版本的读取先询问 Host 的注册表比较,npmmirror 响应最快时传入 `npmFileSources: ['npmmirror']`;只有某个键最新一次的读取才会让该部分得到结果;**重试**要求 Host 重新读取,而不是返回其缓存的结果;连接重置时丢弃所有读取结果,因为重启后的 Host 不再知道其图片使用的文档 id,所以每个已显示的部分会重新读取其文档。图片 URL 基于 `document.baseURI` 解析 `api/plugin-market/detail-file`,因此保留部署前缀,并在 Desktop 中使用 `dsh-app://app/` origin;[`MarkdownText`](../ui-primitives/README.zh.md) 对该路由接受此 origin。 -本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 +本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件索引时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 ----- diff --git a/packages/client/ui-plugin-market/locale/de.json b/packages/client/ui-plugin-market/locale/de.json new file mode 100644 index 0000000000..7575ee4faa --- /dev/null +++ b/packages/client/ui-plugin-market/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin-Erkennung", + "description": "Konfigurierte Plugin-Indizes durchsuchen und deren Pakete installieren." + } +} diff --git a/packages/client/ui-plugin-market/locale/en.json b/packages/client/ui-plugin-market/locale/en.json new file mode 100644 index 0000000000..222f7277ed --- /dev/null +++ b/packages/client/ui-plugin-market/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin discovery", + "description": "Browse configured plugin indexes and install their packages." + } +} diff --git a/packages/client/ui-plugin-market/locale/es.json b/packages/client/ui-plugin-market/locale/es.json new file mode 100644 index 0000000000..a9dd1b071d --- /dev/null +++ b/packages/client/ui-plugin-market/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Descubrimiento de complementos", + "description": "Explore los índices de complementos configurados e instale sus paquetes." + } +} diff --git a/packages/client/ui-plugin-market/locale/fr.json b/packages/client/ui-plugin-market/locale/fr.json new file mode 100644 index 0000000000..e21adf5411 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Découverte de plugins", + "description": "Parcourez les index de plugins configurés et installez leurs paquets." + } +} diff --git a/packages/client/ui-plugin-market/locale/ja.json b/packages/client/ui-plugin-market/locale/ja.json new file mode 100644 index 0000000000..32e9d3c3d7 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン検索 UI", + "description": "設定済みのプラグインインデックスを参照し、プラグインパッケージをインストールします。" + } +} diff --git a/packages/client/ui-plugin-market/locale/pt.json b/packages/client/ui-plugin-market/locale/pt.json new file mode 100644 index 0000000000..6192b57fe6 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Descoberta de plug-ins", + "description": "Navegue pelos índices de plug-ins configurados e instale seus pacotes." + } +} diff --git a/packages/client/ui-plugin-market/locale/ru.json b/packages/client/ui-plugin-market/locale/ru.json new file mode 100644 index 0000000000..c990647d59 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Открытие плагина", + "description": "Просматривайте настроенные индексы плагинов и устанавливайте их пакеты." + } +} diff --git a/packages/client/ui-plugin-market/locale/zh.json b/packages/client/ui-plugin-market/locale/zh.json new file mode 100644 index 0000000000..f01c89d5df --- /dev/null +++ b/packages/client/ui-plugin-market/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件发现界面", + "description": "浏览已配置的插件索引,并安装其中的插件包。" + } +} diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 383f71c19f..9606553275 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-market", "description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -23,6 +23,7 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "dsh": { @@ -63,6 +64,7 @@ "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css index c60637db49..e370480d97 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css @@ -112,8 +112,10 @@ border-radius: var(--dsw-radius-xl); } -.card:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .card:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .icon { @@ -250,8 +252,10 @@ cursor: pointer; } -.crumb:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .crumb:hover { + color: var(--dsw-alias-label-primary); + } } .crumb:focus-visible { @@ -439,6 +443,8 @@ text-decoration: none; } -.link:hover { - text-decoration: underline; +@media (hover: hover) { + .link:hover { + text-decoration: underline; + } } diff --git a/packages/client/ui-plugin-market/src/client/index.ts b/packages/client/ui-plugin-market/src/client/index.ts index 9d92d5da7a..36aed35a01 100644 --- a/packages/client/ui-plugin-market/src/client/index.ts +++ b/packages/client/ui-plugin-market/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type {} from '@deepseek-ai/dsh-plugin-market/types' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import { DiscoverSection } from './DiscoverSection.tsx' -import { en, zh, type PluginMarketLocaleKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh, type PluginMarketLocaleKey } from './locales.ts' import { DiscoverController } from './market-store.ts' export type { DiscoverFace, DiscoverState } from './market-store.ts' @@ -36,7 +36,7 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remo * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-plugin-market: dictionaries') const controller = new DiscoverController(ctx, () => document.baseURI) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller') ctx.effect(() => { diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts index 924ff10b43..52753bd63a 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -8,14 +8,14 @@ export const zh = { retry: '重试', close: '关闭', cancel: '取消', - emptyTitle: '还没有插件市场', - emptyDescription: '添加一个插件市场索引,即可在这里浏览和安装其中的插件', + emptyTitle: '还没有插件索引', + emptyDescription: '添加一个插件索引,即可在这里浏览和安装其中的插件', addFirstSource: '添加索引源', sourcesFailed: '无法读取索引源', manageSources: '管理索引源…', - marketMenu: '切换插件市场,当前为 {market}', + marketMenu: '切换插件索引,当前为 {market}', sourcesTitle: '索引源', - sourcesDescription: '插件市场以一个索引文件的 URL 提供,「发现」从这里列出的索引浏览插件', + sourcesDescription: '插件索引以一个索引文件的 URL 提供,「发现」从这里列出的索引浏览插件', sourcesLabel: '已添加的索引源', sourceUrl: '索引 URL', sourcePlaceholder: 'https://example.com/index.json', @@ -70,7 +70,7 @@ export const zh = { factSourceCode: '源代码', factCategory: '分类', factLabels: '标签', - factMarket: '插件市场', + factMarket: '插件索引', versionsTitle: '版本', versionsLabel: '{name} 的版本', versionDefault: '推荐', @@ -102,14 +102,14 @@ export const en = { retry: 'Retry', close: 'Close', cancel: 'Cancel', - emptyTitle: 'No plugin markets yet', - emptyDescription: 'Add a plugin market index to browse and install its plugins here.', + emptyTitle: 'No plugin indexes yet', + emptyDescription: 'Add a plugin index to browse and install its plugins here.', addFirstSource: 'Add index source', sourcesFailed: 'Could not read the index sources.', manageSources: 'Manage index sources…', - marketMenu: 'Switch plugin market, currently {market}', + marketMenu: 'Switch plugin index, currently {market}', sourcesTitle: 'Index sources', - sourcesDescription: 'A plugin market is published as the URL of one index file. Discover browses the indexes listed here.', + sourcesDescription: 'A plugin index is published as the URL of one index file. Discover browses the indexes listed here.', sourcesLabel: 'Added index sources', sourceUrl: 'Index URL', sourcePlaceholder: 'https://example.com/index.json', @@ -164,7 +164,7 @@ export const en = { factSourceCode: 'Source code', factCategory: 'Category', factLabels: 'Labels', - factMarket: 'Plugin market', + factMarket: 'Plugin index', versionsTitle: 'Versions', versionsLabel: 'Versions of {name}', versionDefault: 'Recommended', @@ -184,3 +184,549 @@ export const en = { detailFailed: 'Could not read the details.', footnotes: 'Footnotes', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'title': '見つける', + 'refresh': '更新', + 'loading': 'インデックスを読み込み中', + 'retry': '再試行', + 'close': '閉じる', + 'cancel': 'キャンセル', + 'emptyTitle': 'プラグインインデックスはまだありません', + 'emptyDescription': 'プラグインインデックスを追加すると、ここでそのプラグインを閲覧してインストールできます。', + 'addFirstSource': 'インデックスソースを追加', + 'sourcesFailed': 'インデックスソースを読み取れませんでした。', + 'manageSources': 'インデックスソースを管理…', + 'marketMenu': 'プラグインインデックスを切り替え、現在は{market}', + 'sourcesTitle': 'インデックスソース', + 'sourcesDescription': 'プラグインインデックスは1つのインデックスファイルのURLとして公開されます。「見つける」ではここに一覧されたインデックスを閲覧します。', + 'sourcesLabel': '追加済みのインデックスソース', + 'sourceUrl': 'インデックスURL', + 'sourcePlaceholder': 'https://example.com/index.json', + 'addSource': '追加', + 'removeSource': '削除', + 'removeSourceNamed': '{url}を削除', + 'sourcesTrust': 'インデックスは第三者提供です。信頼できるソースのみ追加してください。インストールされたプラグインはあなたの権限で実行されます。', + 'sourceInvalid': 'ユーザー名、パスワード、#フラグメントを含まないhttpまたはhttpsのアドレスを使用してください。', + 'sourceDuplicate': 'このインデックスソースはすでに追加されています。', + 'sourceUnavailable': 'ここではインデックスソースを保存できません。', + 'sourceFailed': 'インデックスソースを保存できませんでした。', + 'readFailed': 'このインデックスを読み取れませんでした。', + 'readInvalid': 'このインデックスは有効な形式ではありません。', + 'readChanged': '読み込み中にこのインデックスが変更されました。', + 'search': 'プラグインを検索', + 'filter': 'ラベル', + 'filterCount': 'ラベル · {count}', + 'sortBy': '並び替え: {sort}', + 'sortRelevance': '一致度順', + 'sortDefault': 'デフォルト順', + 'sortPublished': '公開日', + 'switchToAscending': '昇順で並び替え', + 'switchToDescending': '降順で並び替え', + 'categoriesLabel': 'カテゴリ', + 'allCategories': 'すべて', + 'emptyIndex': 'このインデックスにはプラグインがありません。', + 'noMatches': '一致するプラグインがありません。', + 'showMore': 'さらに表示', + 'entryMeta': '{version} · {date}', + 'entryMetaAuthor': '{version} · {author} · {date}', + 'openEntry': '{name}を表示', + 'install': 'インストール', + 'installTitle': '{name}をインストール', + 'installVersionTitle': '{name} {version}をインストール', + 'openInstalled': 'インストール済みで{name}を表示', + 'statusInstalled': 'インストール済み', + 'statusUpdate': 'アップデートあり', + 'statusProvided': 'DSH が提供', + 'incompatible': 'このバージョンは実行中の DSH {runtime} と互換性がありません。DSH {range} が必要です。', + 'noCompatibleVersion': 'DSH {runtime} と互換性のあるバージョンはありません', + 'exempted': 'このバージョンは実行中の DSH と互換性がありませんが、互換性の例外が設定されています。', + 'backToList': '見つける', + 'factSource': 'ソース', + 'factPackage': 'パッケージ', + 'factRegistry': 'レジストリ', + 'factRepository': 'リポジトリ', + 'factCommit': 'コミット', + 'factDirectory': 'ディレクトリ', + 'factPublished': '公開日', + 'factAuthor': '作者', + 'factHomepage': 'ホームページ', + 'factSourceCode': 'ソースコード', + 'factCategory': 'カテゴリ', + 'factLabels': 'ラベル', + 'factMarket': 'プラグインインデックス', + 'versionsTitle': 'バージョン', + 'versionsLabel': '{name} のバージョン', + 'versionDefault': '推奨', + 'versionInstalled': 'インストール済み', + 'versionIncompatible': '非互換', + 'versionExempted': '例外適用', + 'versionPrerelease': 'プレリリース', + 'confirmTitle': '「{name}」をインストールしますか?', + 'installTrust': 'プラグインはこのマシン上であなたの権限で実行されます。提供元が信頼できることを確認してください。', + 'detailTitle': '詳細', + 'detailLoading': '詳細を読み込み中', + 'detailTooLarge': '詳細またはそのリポジトリがサイズ上限を超えたため、読み込まれませんでした。', + 'detailNetwork': '詳細の取得元に接続できませんでした。', + 'detailDenied': '詳細のリポジトリにアクセスできませんでした。Git の認証情報を確認してください。', + 'detailGitUnavailable': '詳細は Git で読み取る必要があるリポジトリにありますが、Git を実行できませんでした。Git をインストールしてから再試行してください。', + 'detailInvalid': 'プラグインが無効な詳細ファイルを宣言しています。', + 'detailFailed': '詳細を読み取れませんでした。', + 'footnotes': '脚注', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + title: 'Descubrir', + refresh: 'Actualizar', + loading: 'leyendo el índice', + retry: 'Reintentar', + close: 'Cerrar', + cancel: 'Cancelar', + emptyTitle: 'Aún no hay índices de complementos', + emptyDescription: 'Agregue un índice de complementos para explorar e instalar sus complementos aquí.', + addFirstSource: 'Agregar fuente de índice', + sourcesFailed: 'No se pudieron leer las fuentes del índice.', + manageSources: 'Administrar fuentes de índice...', + marketMenu: 'Cambiar el índice de complementos; actual: {market}', + sourcesTitle: 'Fuentes del índice', + sourcesDescription: 'Un índice de complementos se publica mediante la URL de un archivo de índice. Descubrir explora los índices enumerados aquí.', + sourcesLabel: 'Fuentes de índice agregadas', + sourceUrl: 'URL del índice', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Añadir', + removeSource: 'Quitar', + removeSourceNamed: 'Quitar {url}', + sourcesTrust: 'Los índices provienen de terceros. Agregue solo fuentes en las que confíe: sus complementos se ejecutan con sus permisos una vez instalados.', + sourceInvalid: 'Utilice una dirección http o https sin nombre de usuario, contraseña o fragmento #.', + sourceDuplicate: 'Esta fuente de índice ya está agregada.', + sourceUnavailable: 'Las fuentes del índice no se pueden guardar aquí.', + sourceFailed: 'No se pudieron guardar las fuentes del índice.', + readFailed: 'No se pudo leer este índice.', + readInvalid: 'Este índice no tiene un formato válido.', + readChanged: 'Este índice cambió durante la carga.', + search: 'Complementos de búsqueda', + filter: 'Etiquetas', + filterCount: 'Etiquetas · {count}', + sortBy: 'Tipo: {sort}', + sortRelevance: 'mejor partido', + sortDefault: 'Orden predeterminado', + sortPublished: 'Fecha de publicación', + switchToAscending: 'Orden ascendente', + switchToDescending: 'Orden descendente', + categoriesLabel: 'Categorías', + allCategories: 'Todos', + emptyIndex: 'Este índice no incluye complementos.', + noMatches: 'Ningún complemento coincide.', + showMore: 'Mostrar más', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Ver {name}', + install: 'Instalar', + installTitle: 'Instalar {name}', + installVersionTitle: 'Instalar {name} {version}', + openInstalled: 'Ver {name} en Instalado', + statusInstalled: 'Instalado', + statusUpdate: 'Actualización disponible', + statusProvided: 'Proporcionado por DSH', + incompatible: 'Esta versión no es compatible con DSH {runtime} en ejecución; requiere DSH {range}.', + noCompatibleVersion: 'Ninguna versión es compatible con DSH {runtime}', + exempted: 'Esta versión no es compatible con DSH en ejecución, pero usted establece una exención de compatibilidad para ella.', + backToList: 'Descubrir', + factSource: 'Fuente', + factPackage: 'Paquete', + factRegistry: 'Registro', + factRepository: 'Repositorio', + factCommit: 'comprometerse', + factDirectory: 'Directorio', + factPublished: 'Publicado', + factAuthor: 'Autor', + factHomepage: 'Página de inicio', + factSourceCode: 'código fuente', + factCategory: 'Categoría', + factLabels: 'Etiquetas', + factMarket: 'Índice de complementos', + versionsTitle: 'Versiones', + versionsLabel: 'Versiones de {name}', + versionDefault: 'Recomendado', + versionInstalled: 'Instalado', + versionIncompatible: 'incompatible', + versionExempted: 'exento', + versionPrerelease: 'Presentación', + confirmTitle: '¿Instalar "{name}"?', + installTrust: 'Los complementos se ejecutan en esta máquina con sus permisos. Asegúrate de confiar en el origen de este.', + detailTitle: 'Detalles', + detailLoading: 'Detalles de lectura', + detailTooLarge: 'Los detalles o su repositorio exceden el límite de tamaño y no se cargaron.', + detailNetwork: 'No se pudo llegar a la fuente de los detalles.', + detailDenied: 'No se pudo acceder al repositorio de detalles. Verifique sus credenciales de Git.', + detailGitUnavailable: 'Los detalles están en un repositorio que debe leerse con Git y Git no se pudo ejecutar. Instale Git y vuelva a intentarlo.', + detailInvalid: 'El complemento declara un archivo de detalles no válido.', + detailFailed: 'No se pudieron leer los detalles.', + footnotes: 'Notas a pie de página', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + title: 'Découvrez', + refresh: 'Actualiser', + loading: "Lire l'index", + retry: 'Réessayer', + close: 'Fermer', + cancel: 'Annuler', + emptyTitle: 'Aucun index de plugins pour le moment', + emptyDescription: 'Ajoutez un index de plugins pour parcourir et installer ses plugins ici.', + addFirstSource: "Ajouter une source d'index", + sourcesFailed: "Impossible de lire les sources de l'index.", + manageSources: "Gérer les sources d'index…", + marketMenu: 'Changer d’index de plugins, actuellement {market}', + sourcesTitle: "Sources d'index", + sourcesDescription: 'Un index de plugins est publié à l’URL d’un fichier d’index. Découvrir parcourt les index répertoriés ici.', + sourcesLabel: "Sources d'index ajoutées", + sourceUrl: "URL de l'index", + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Ajouter', + removeSource: 'Supprimer', + removeSourceNamed: 'Supprimer {url}', + sourcesTrust: "Les index proviennent de tiers. Ajoutez uniquement des sources auxquelles vous faites confiance : leurs plugins s'exécutent avec vos autorisations une fois installés.", + sourceInvalid: "Utilisez une adresse http ou https sans nom d'utilisateur, mot de passe ou fragment #.", + sourceDuplicate: "Cette source d'index est déjà ajoutée.", + sourceUnavailable: "Les sources d'index ne peuvent pas être enregistrées ici.", + sourceFailed: "Impossible d'enregistrer les sources de l'index.", + readFailed: 'Impossible de lire cet index.', + readInvalid: "Cet index n'est pas dans un format valide.", + readChanged: 'Cet index a changé lors du chargement.', + search: 'Plugins de recherche', + filter: 'Étiquettes', + filterCount: 'Étiquettes · {count}', + sortBy: 'Trier : {sort}', + sortRelevance: 'Meilleure correspondance', + sortDefault: 'Ordre par défaut', + sortPublished: 'Date de publication', + switchToAscending: 'Trier par ordre croissant', + switchToDescending: 'Trier par ordre décroissant', + categoriesLabel: 'Catégories', + allCategories: 'Tout', + emptyIndex: 'Cet index ne répertorie aucun plugin.', + noMatches: 'Aucun plugin ne correspond.', + showMore: 'Afficher plus', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Voir {name}', + install: 'Installer', + installTitle: 'Installer {name}', + installVersionTitle: 'Installer {name} {version}', + openInstalled: 'Afficher {name} dans Installé', + statusInstalled: 'Installé', + statusUpdate: 'Mise à jour disponible', + statusProvided: 'Fourni par DSH', + incompatible: "Cette version n'est pas compatible avec le DSH {runtime} en cours d'exécution ; cela nécessite DSH {range}.", + noCompatibleVersion: "Aucune version n'est compatible avec DSH {runtime}", + exempted: "Cette version n'est pas compatible avec le DSH en cours d'exécution, mais vous avez défini une exemption de compatibilité pour celui-ci.", + backToList: 'Découvrez', + factSource: 'Source', + factPackage: 'Forfait', + factRegistry: 'Registre', + factRepository: 'Référentiel', + factCommit: "S'engager", + factDirectory: 'Annuaire', + factPublished: 'Publié', + factAuthor: 'Auteur', + factHomepage: "Page d'accueil", + factSourceCode: 'Code source', + factCategory: 'Catégorie', + factLabels: 'Étiquettes', + factMarket: 'Index de plugins', + versionsTitle: 'Versions', + versionsLabel: 'Versions de {name}', + versionDefault: 'Recommandé', + versionInstalled: 'Installé', + versionIncompatible: 'Incompatible', + versionExempted: 'Exonéré', + versionPrerelease: 'Avant-première', + confirmTitle: 'Installer « {name} » ?', + installTrust: "Les plugins s'exécutent sur cette machine avec vos autorisations. Assurez-vous de faire confiance à l'origine de celui-ci.", + detailTitle: 'Détails', + detailLoading: 'Détails de lecture', + detailTooLarge: "Les détails ou leur référentiel dépassent la limite de taille et n'ont pas été chargés.", + detailNetwork: "Impossible d'atteindre la source des détails.", + detailDenied: "Impossible d'accéder au référentiel des détails. Vérifiez vos informations d'identification Git.", + detailGitUnavailable: "Les détails se trouvent dans un référentiel qui doit être lu avec Git, et Git n'a pas pu s'exécuter. Installez Git, puis réessayez.", + detailInvalid: 'Le plugin déclare un fichier de détails invalide.', + detailFailed: 'Impossible de lire les détails.', + footnotes: 'Notes de bas de page', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + title: 'Entdecken', + refresh: 'Aktualisieren', + loading: 'Den Index lesen', + retry: 'Erneut versuchen', + close: 'Schließen', + cancel: 'Abbrechen', + emptyTitle: 'Noch keine Plugin-Indizes', + emptyDescription: 'Einen Plugin-Index hinzufügen, um dessen Plugins hier zu durchsuchen und zu installieren.', + addFirstSource: 'Indexquelle hinzufügen', + sourcesFailed: 'Die Indexquellen konnten nicht gelesen werden.', + manageSources: 'Indexquellen verwalten…', + marketMenu: 'Plugin-Index wechseln, aktuell {market}', + sourcesTitle: 'Indexquellen', + sourcesDescription: 'Ein Plugin-Index wird über die URL einer Indexdatei veröffentlicht. Entdecken durchsucht die hier aufgeführten Indizes.', + sourcesLabel: 'Indexquellen hinzugefügt', + sourceUrl: 'Index-URL', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Hinzufügen', + removeSource: 'Entfernen', + removeSourceNamed: 'Entfernen Sie {url}', + sourcesTrust: 'Indizes stammen von Dritten. Fügen Sie nur Quellen hinzu, denen Sie vertrauen: Ihre Plugins werden nach der Installation mit Ihren Berechtigungen ausgeführt.', + sourceInvalid: 'Verwenden Sie eine http- oder https-Adresse ohne Benutzernamen, Passwort oder #-Fragment.', + sourceDuplicate: 'Diese Indexquelle wurde bereits hinzugefügt.', + sourceUnavailable: 'Indexquellen können hier nicht gespeichert werden.', + sourceFailed: 'Die Indexquellen konnten nicht gespeichert werden.', + readFailed: 'Dieser Index konnte nicht gelesen werden.', + readInvalid: 'Dieser Index hat kein gültiges Format.', + readChanged: 'Dieser Index hat sich beim Laden geändert.', + search: 'Such-Plugins', + filter: 'Etiketten', + filterCount: 'Etiketten · {count}', + sortBy: 'Sortierung: {sort}', + sortRelevance: 'Beste Übereinstimmung', + sortDefault: 'Standardreihenfolge', + sortPublished: 'Veröffentlichungsdatum', + switchToAscending: 'Aufsteigend sortieren', + switchToDescending: 'Absteigend sortieren', + categoriesLabel: 'Kategorien', + allCategories: 'Alle', + emptyIndex: 'Dieser Index listet keine Plugins auf.', + noMatches: 'Es stimmen keine Plugins überein.', + showMore: 'Mehr anzeigen', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Siehe {name}', + install: 'Installieren', + installTitle: 'Installieren Sie {name}', + installVersionTitle: 'Installieren Sie {name} {version}', + openInstalled: '{name} unter „Installiert“ anzeigen', + statusInstalled: 'Installiert', + statusUpdate: 'Update verfügbar', + statusProvided: 'Bereitgestellt von DSH', + incompatible: 'Diese Version ist nicht mit dem laufenden DSH {runtime} kompatibel; es erfordert DSH {range}.', + noCompatibleVersion: 'Keine Version ist mit DSH {runtime} kompatibel', + exempted: 'Diese Version ist nicht mit der laufenden Version DSH kompatibel, Sie haben jedoch eine Kompatibilitätsausnahme dafür festgelegt.', + backToList: 'Entdecken', + factSource: 'Quelle', + factPackage: 'Paket', + factRegistry: 'Registrierung', + factRepository: 'Repository', + factCommit: 'Begehen', + factDirectory: 'Verzeichnis', + factPublished: 'Veröffentlicht', + factAuthor: 'Autor', + factHomepage: 'Homepage', + factSourceCode: 'Quellcode', + factCategory: 'Kategorie', + factLabels: 'Etiketten', + factMarket: 'Plugin-Index', + versionsTitle: 'Versionen', + versionsLabel: 'Versionen von {name}', + versionDefault: 'Empfohlen', + versionInstalled: 'Installiert', + versionIncompatible: 'Inkompatibel', + versionExempted: 'Ausgenommen', + versionPrerelease: 'Vorabversion', + confirmTitle: '„{name}“ installieren?', + installTrust: 'Plugins werden mit Ihren Berechtigungen auf diesem Computer ausgeführt. Stellen Sie sicher, dass Sie darauf vertrauen, woher dieser kommt.', + detailTitle: 'Details', + detailLoading: 'Details lesen', + detailTooLarge: 'Die Details oder ihr Repository überschreiten die Größenbeschränkung und wurden nicht geladen.', + detailNetwork: 'Die Quelle der Details konnte nicht erreicht werden.', + detailDenied: 'Auf das Repository mit den Details konnte nicht zugegriffen werden. Überprüfen Sie Ihre Git-Anmeldeinformationen.', + detailGitUnavailable: 'Die Details befinden sich in einem Repository, das mit Git gelesen werden muss, und Git konnte nicht ausgeführt werden. Installieren Sie Git und versuchen Sie es erneut.', + detailInvalid: 'Das Plugin deklariert eine ungültige Detaildatei.', + detailFailed: 'Die Details konnten nicht gelesen werden.', + footnotes: 'Fußnoten', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + title: 'Откройте для себя', + refresh: 'Обновить', + loading: 'Чтение индекса', + retry: 'Повторить', + close: 'Закрыть', + cancel: 'Отмена', + emptyTitle: 'Индексы плагинов ещё не добавлены', + emptyDescription: 'Добавьте индекс плагинов, чтобы просматривать и устанавливать его плагины здесь.', + addFirstSource: 'Добавить источник индекса', + sourcesFailed: 'Не удалось прочитать исходники индекса.', + manageSources: 'Управление источниками индексов…', + marketMenu: 'Переключить индекс плагинов, текущий: {market}', + sourcesTitle: 'Источники индекса', + sourcesDescription: 'Индекс плагинов публикуется по URL файла индекса. В разделе «Обнаружение» можно просматривать перечисленные здесь индексы.', + sourcesLabel: 'Добавлены источники индекса', + sourceUrl: 'URL-адрес индекса', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Добавить', + removeSource: 'Удалить', + removeSourceNamed: 'Удалить {url}', + sourcesTrust: 'Индексы поступают от третьих лиц. Добавляйте только те источники, которым вы доверяете: их плагины запускаются с вашими разрешениями после установки.', + sourceInvalid: 'Используйте адрес http или https без имени пользователя, пароля или фрагмента #.', + sourceDuplicate: 'Этот источник индекса уже добавлен.', + sourceUnavailable: 'Источники индекса здесь сохранить нельзя.', + sourceFailed: 'Не удалось сохранить источники индекса.', + readFailed: 'Не удалось прочитать этот индекс.', + readInvalid: 'Этот индекс имеет недопустимый формат.', + readChanged: 'Этот индекс менялся при загрузке.', + search: 'Поиск плагинов', + filter: 'Этикетки', + filterCount: 'Этикетки · {count}', + sortBy: 'Сортировать: {sort}', + sortRelevance: 'Лучший матч', + sortDefault: 'Порядок по умолчанию', + sortPublished: 'Дата публикации', + switchToAscending: 'Сортировать по возрастанию', + switchToDescending: 'Сортировать по убыванию', + categoriesLabel: 'Категории', + allCategories: 'Все', + emptyIndex: 'В этом индексе нет плагинов.', + noMatches: 'Никакие плагины не подходят.', + showMore: 'Показать больше', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Посмотреть {name}', + install: 'Установить', + installTitle: 'Установить {name}', + installVersionTitle: 'Установить {name} {version}', + openInstalled: 'Посмотреть {name} в списке «Установлено»', + statusInstalled: 'Установлено', + statusUpdate: 'Доступно обновление', + statusProvided: 'Предоставлено DSH', + incompatible: 'Эта версия несовместима с работающим DSH {runtime}; для этого требуется DSH {range}.', + noCompatibleVersion: 'Ни одна версия не совместима с DSH {runtime}.', + exempted: 'Эта версия несовместима с работающим DSH, но вы установили для нее исключение совместимости.', + backToList: 'Откройте для себя', + factSource: 'Источник', + factPackage: 'Пакет', + factRegistry: 'Реестр', + factRepository: 'Репозиторий', + factCommit: 'Зафиксировать', + factDirectory: 'Каталог', + factPublished: 'Опубликовано', + factAuthor: 'Автор', + factHomepage: 'Домашняя страница', + factSourceCode: 'Исходный код', + factCategory: 'Категория', + factLabels: 'Этикетки', + factMarket: 'Индекс плагинов', + versionsTitle: 'Версии', + versionsLabel: 'Версии {name}', + versionDefault: 'Рекомендуется', + versionInstalled: 'Установлено', + versionIncompatible: 'Несовместимый', + versionExempted: 'освобожден', + versionPrerelease: 'Пререлиз', + confirmTitle: 'Установить "{name}"?', + installTrust: 'Плагины запускаются на этом компьютере с вашего разрешения. Убедитесь, что вы доверяете источнику этого сообщения.', + detailTitle: 'Подробности', + detailLoading: 'Читаем подробности', + detailTooLarge: 'Детали или их репозиторий превышают предельный размер и не были загружены.', + detailNetwork: 'До источника подробностей добраться не удалось.', + detailDenied: 'Не удалось получить доступ к хранилищу реквизитов. Проверьте свои учетные данные Git.', + detailGitUnavailable: 'Подробности находятся в репозитории, который необходимо прочитать с помощью Git, и Git не удалось запустить. Установите Git и повторите попытку.', + detailInvalid: 'Плагин объявляет неверный файл сведений.', + detailFailed: 'Подробности прочитать не удалось.', + footnotes: 'Сноски', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + title: 'Descubra', + refresh: 'Atualizar', + loading: 'Lendo o índice', + retry: 'Tentar novamente', + close: 'Fechar', + cancel: 'Cancelar', + emptyTitle: 'Ainda não há índices de plug-ins', + emptyDescription: 'Adicione um índice de plug-ins para navegar e instalar seus plug-ins aqui.', + addFirstSource: 'Adicionar fonte de índice', + sourcesFailed: 'Não foi possível ler as fontes do índice.', + manageSources: 'Gerenciar fontes de índice…', + marketMenu: 'Mudar de índice de plug-ins, atual: {market}', + sourcesTitle: 'Fontes de índice', + sourcesDescription: 'Um índice de plug-ins é publicado pela URL de um arquivo de índice. Descobrir navega pelos índices listados aqui.', + sourcesLabel: 'Fontes de índice adicionadas', + sourceUrl: 'URL do índice', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Adicionar', + removeSource: 'Remover', + removeSourceNamed: 'Remover {url}', + sourcesTrust: 'Os índices vêm de terceiros. Adicione apenas fontes em que você confia: seus plug-ins são executados com suas permissões, uma vez instalados.', + sourceInvalid: 'Use um endereço http ou https sem nome de usuário, senha ou fragmento #.', + sourceDuplicate: 'Esta fonte de índice já foi adicionada.', + sourceUnavailable: 'As fontes do índice não podem ser salvas aqui.', + sourceFailed: 'Não foi possível salvar as fontes do índice.', + readFailed: 'Não foi possível ler este índice.', + readInvalid: 'Este índice não está em um formato válido.', + readChanged: 'Este índice mudou durante o carregamento.', + search: 'Plug-ins de pesquisa', + filter: 'Etiquetas', + filterCount: 'Marcadores · {count}', + sortBy: 'Classificar: {sort}', + sortRelevance: 'Melhor correspondência', + sortDefault: 'Ordem padrão', + sortPublished: 'Data de publicação', + switchToAscending: 'Classificar em ordem crescente', + switchToDescending: 'Classificar em ordem decrescente', + categoriesLabel: 'Categorias', + allCategories: 'Todos', + emptyIndex: 'Este índice não lista plug-ins.', + noMatches: 'Nenhum plug-in corresponde.', + showMore: 'Mostrar mais', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Ver {name}', + install: 'Instalar', + installTitle: 'Instalar {name}', + installVersionTitle: 'Instalar {name} {version}', + openInstalled: 'Ver {name} em Instalado', + statusInstalled: 'Instalado', + statusUpdate: 'Atualização disponível', + statusProvided: 'Fornecido por DSH', + incompatible: 'Esta versão não é compatível com o DSH {runtime} em execução; requer DSH {range}.', + noCompatibleVersion: 'Nenhuma versão é compatível com DSH {runtime}', + exempted: 'Esta versão não é compatível com o DSH em execução, mas você definiu uma isenção de compatibilidade para ela.', + backToList: 'Descubra', + factSource: 'Fonte', + factPackage: 'Pacote', + factRegistry: 'Registro', + factRepository: 'Repositório', + factCommit: 'Confirmar', + factDirectory: 'Diretório', + factPublished: 'Publicado', + factAuthor: 'Autor', + factHomepage: 'Página inicial', + factSourceCode: 'Código fonte', + factCategory: 'Categoria', + factLabels: 'Etiquetas', + factMarket: 'Índice de plug-ins', + versionsTitle: 'Versões', + versionsLabel: 'Versões de {name}', + versionDefault: 'Recomendado', + versionInstalled: 'Instalado', + versionIncompatible: 'Incompatível', + versionExempted: 'Isento', + versionPrerelease: 'Pré-lançamento', + confirmTitle: 'Instalar "{name}"?', + installTrust: 'Os plug-ins são executados nesta máquina com suas permissões. Certifique-se de confiar de onde este vem.', + detailTitle: 'Detalhes', + detailLoading: 'Lendo detalhes', + detailTooLarge: 'Os detalhes ou seu repositório excedem o limite de tamanho e não foram carregados.', + detailNetwork: 'Não foi possível acessar a fonte dos detalhes.', + detailDenied: 'Não foi possível acessar o repositório dos detalhes. Verifique suas credenciais do Git.', + detailGitUnavailable: 'Os detalhes estão em um repositório que deve ser lido com o Git, e o Git não pôde ser executado. Instale o Git e tente novamente.', + detailInvalid: 'O plugin declara um arquivo de detalhes inválido.', + detailFailed: 'Não foi possível ler os detalhes.', + footnotes: 'Notas de rodapé', +} satisfies Record diff --git a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx index 88ee3e6cd3..965275e9f8 100644 --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -50,7 +50,7 @@ const LISTING: MarketSearchResult = { fetchedAt: 1, total: 6, nextOffset: null, - title: { en: 'Alpha market', zh: '甲市场' }, + title: { en: 'Alpha index', zh: '甲索引' }, metadata: { categories: { tools: { title: 'Tools' }, themes: { title: 'Themes' } }, labels: { official: { title: 'Official' }, beta: { title: 'Beta' } }, @@ -231,7 +231,7 @@ describe('DiscoverSection', () => { expect(screen.getByText(en.emptyIndex)).toBeTruthy() }) - it('sorts by publish date, a market sort key, either direction, and back to the index order', () => { + it('sorts by publish date, an index sort key, either direction, and back to the index order', () => { const { actions, answer } = renderTab() openMenu(/^Sort: Default order/) pick(en.sortPublished) @@ -256,7 +256,7 @@ describe('DiscoverSection', () => { answer(INITIAL_QUERY) openMenu(/^Sort: Default order/) expect(screen.queryByRole('menuitem', { name: en.switchToAscending })).toBeNull() - for (const name of [/^Sort:/, en.filter, /^Switch plugin market/]) { + for (const name of [/^Sort:/, en.filter, /^Switch plugin index/]) { fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() openMenu(name) @@ -264,17 +264,17 @@ describe('DiscoverSection', () => { } fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() - openMenu(/^Switch plugin market/) - openMenu(/^Switch plugin market/) + openMenu(/^Switch plugin index/) + openMenu(/^Switch plugin index/) expect(screen.queryByRole('menu')).toBeNull() }) - it('switches markets and opens source management from the market menu', () => { + it('switches indexes and opens source management from the index menu', () => { const { actions } = renderTab() - openMenu('Switch plugin market, currently Alpha market') + openMenu('Switch plugin index, currently Alpha index') pick('b.example') expect(actions.select).toHaveBeenCalledWith(B) - openMenu(/^Switch plugin market/) + openMenu(/^Switch plugin index/) pick(en.manageSources) expect(actions.toggleSources).toHaveBeenCalledOnce() }) @@ -287,7 +287,7 @@ describe('DiscoverSection', () => { expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy() expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy() expect(detail.getByText('dsh-a')).toBeTruthy() - for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy() + for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha index']) expect(detail.getByText(value)).toBeTruthy() const homepage = detail.getByRole('link', { name: 'https://example.com/alpha#readme' }) expect([homepage.getAttribute('href'), homepage.getAttribute('target'), homepage.getAttribute('rel')]) .toEqual(['https://example.com/alpha#readme', '_blank', 'noopener noreferrer']) @@ -420,14 +420,14 @@ describe('DiscoverSection', () => { expect(screen.getByRole('status', { name: en.loading })).toBeTruthy() expect(screen.getByRole('button', { name: en.refresh }).hasAttribute('disabled')).toBe(true) set({ listing: { url: B, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, url: B, entries: [], total: 0 }, answered: INITIAL_QUERY, failure: null } }) - expect(screen.getByRole('button', { name: 'Switch plugin market, currently b.example' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Switch plugin index, currently b.example' })).toBeTruthy() }) it('manages sources in a dialog with the trust notice and each refusal', () => { const { actions, set } = renderTab({ editing: true, draft: 'https://c.example/index.json', indexes: [{ url: A, title: LISTING.title }, { url: B, error: 'HTTP 404' }] }) const dialog = within(screen.getByRole('dialog', { name: en.sourcesTitle })) expect(dialog.getByText(en.sourcesTrust)).toBeTruthy() - expect(dialog.getByText('Alpha market')).toBeTruthy() + expect(dialog.getByText('Alpha index')).toBeTruthy() expect(dialog.getByText(B)).toBeTruthy() expect(dialog.getByText('HTTP 404')).toBeTruthy() fireEvent.click(dialog.getByRole('button', { name: `Remove ${B}` })) @@ -453,7 +453,7 @@ describe('DiscoverSection', () => { it('renders the Chinese copy and dates', () => { renderTab({}, zh) - expect(screen.getByRole('button', { name: '切换插件市场,当前为 甲市场' })).toBeTruthy() + expect(screen.getByRole('button', { name: '切换插件索引,当前为 甲索引' })).toBeTruthy() expect(card('dsh-a').getByText('甲')).toBeTruthy() expect(card('dsh-a').getByText(/^1\.0\.0 · Jane · 2026年9月/)).toBeTruthy() expect(card('dsh-b').getByText(zh.statusInstalled)).toBeTruthy() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a63e9c8c49..cad46c62fc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -15,26 +15,26 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-primitives/use-this-package: - en: 7bae72c97d0449f5 - zh: 4589ecc2b216edc5 + en: 10be7f75a1576575 + zh: abf2905d89b0da48 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cb874a0ca53a6747 - zh: 31179193dd1d6e12 + en: 3c79351ff2210ccb + zh: 4cbe07ded89bbaa1 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 09c61f61c560f8a2 - zh: 895ac73add1199b8 + en: b3744373b4b2c1fb + zh: 2dbde8ee0bfa5f28 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3a31ad8de6dd0af8 - zh: 6b22881a929c33df + en: 2daec758b1f35563 + zh: 32684be1f8b1b4c3 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: - en: 4aef65dea11de083 - zh: abc22cd7c65cdf38 + en: a943603b74080ef5 + zh: 43f7a0188aff271b /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: en: 846ac0f563a53a64 zh: bb89d7647bdcc1ab /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: - en: 44eb32370cbeafe8 - zh: 728009c66375c6bb + en: fd510d34404eb090 + zh: d05b0890db929ae6 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality: en: cb61e1a37d6c27e5 zh: 1f3fa79dd5ab2e45 @@ -45,8 +45,8 @@ en: 45f4cd1ef96bb6d2 zh: f264d25b780ad1dc /deepseek-ai-dsh-client-ui-primitives/further-exploration: - en: e82b713cdb2f954b - zh: a877f34b148f433f + en: abbbdbc9a7540743 + zh: cf5bbeaedb3696d0 /deepseek-ai-dsh-client-ui-primitives/model-experience: en: 3120df665299c634 zh: 1d9f365d091d0d2a @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 22fc14aa3a224552 - zh: aa6309e84ad8c2a3 + en: 9c5bc17e35af9d0b + zh: 4f1a142c7d56cf72 /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 54cb899ba0..95db022946 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,7 +25,9 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package -`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. +`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. + +`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers. Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. @@ -46,20 +48,24 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | -| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | +| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | +| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | +| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | +| `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | @@ -67,10 +73,12 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. | +| `useClipboardCopy` | Copies the current text with one-second success feedback, prevents overlapping or success-window repeats, and cancels its reset timer on unmount; late write completion cannot update an unmounted control. | +| `UserMessageBubble` | User-message background, radius, content inset, typography, and plain-text wrapping. Accepts native div props; callers own row alignment, width, content, and actions. | | `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. | | `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. `IconDragHandleOutlineRegular`/`Medium` draw a three-line grip for reordering. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium`, `PermissionIconAutoReviewRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, full-access, and auto-review choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. | @@ -88,21 +96,23 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. -`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click. +`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. -`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. +`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. `as="span"` preserves an inline text container. ### Rendering agent output -The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. +The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). + +`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. `CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown shares this toolbar, spacing, and code typography while retaining monochrome text. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout. @@ -110,11 +120,13 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. -`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Its transparent hover region connects the anchor to the panel across the visual gap, so crossing the gap does not activate content beneath it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. + +`DraggableImage` receives explicit `fileDrag: FileDragActions`, also supplied through `MarkdownDelegateProvider.fileImages.fileDrag`. The [file-drag plugin](../ui-file-drag/README.md) owns preparation, transfer, and localized failure feedback; the image primitive manages gestures and cancellation. Resource-backed images prepare on primary pointer down; other sources also prepare on hover. Releasing inside the image preserves preparation for the next gesture; pointer leave releases unused preparation. Markdown export names use the decoded path basename. Panning images skip preparation. Starting before preparation finishes cancels that gesture quietly; an actual preparation failure reports a retry message. Managed Web transfers carry an application-local token and a `DownloadURL` pointing to a plugin-owned Blob URL, with no `text/plain` payload. ### Localizing copy -The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. +The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. ----- @@ -147,7 +159,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI escape parsing (`anser`) and terminal card rendering | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | -| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components | +| [`src/icons/`](src/icons/) | Default product glyph artwork, its `ICON_SYMBOLS` sprite documents, and the size-neutral `Regular` and `Medium` components that reference those symbols | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting | | [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids | @@ -184,6 +196,8 @@ These pages place the atoms in the client stack and the design system. ----- +`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text; editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation. + ## Model Experience @@ -200,15 +214,20 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. +- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs. - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations. - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. +- **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. +- **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)). +- **A fence after a `\verb` delimiter is content** — the tokenizer reads the six characters before a fence, so the `$` right after `\verb` or `\verb*` is that command's delimiter rather than a closing fence, and a region whose text ends in `\verb` (`\verb**\verb*$$`) reads the same way. A fence inside an unterminated region (`\verb|$$`) still closes the block there, because the region's content is not parsed. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR colors, carriage return, backspace, erase-in-line, tab stops, and character width are honored; absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 4a25f823d1..910185907b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,7 +25,9 @@ kind: "package-library" ## 使用本包 -`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 +`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 + +`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。 弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 @@ -46,20 +48,24 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | -| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | +| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | +| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | +| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | +| `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | @@ -67,10 +73,12 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | +| `useClipboardCopy` | 复制当前文本并显示一秒成功反馈,阻止写入期间及成功反馈期间的重复操作,卸载时清除重置计时器;迟到的写入结果不会更新已卸载控件。 | +| `UserMessageBubble` | 用户消息的背景、圆角、内容内边距、字体与纯文本换行规则。接收原生 div props;行对齐、宽度、内容和操作由调用方拥有。 | | `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 | | `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`IconDragHandleOutlineRegular`/`Medium` 以三条横线表示可拖动的排序手柄。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium`、`PermissionIconAutoReviewRegular`/`Medium` | 只读、工作区写入、完全访问与自动审查选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 | @@ -88,21 +96,23 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 -`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。 +`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 -`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。`as="span"` 保留行内文本容器。 ### 渲染 agent 输出 -最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 +纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 + +`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 `CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 @@ -110,11 +120,13 @@ kind: "package-library" `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 -`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 +`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 透明悬停区域连接锚点与弹层之间的视觉空隙,鼠标经过时不会激活下方内容。 + +`DraggableImage` 接收显式的 `fileDrag: FileDragActions`,受管理的 Markdown 图片通过 `MarkdownDelegateProvider.fileImages.fileDrag` 接收同一组回调。[文件拖放插件](../ui-file-drag/README.zh.md) 负责准备、传递与本地化失败提示;图片原语负责手势和取消。带资源引用的图片仅在主指针按下时准备,其他来源也可在悬停时准备。在图片内松开保留准备供下一次手势使用;指针移开释放未使用的准备,平移模式跳过准备。Markdown 导出名取已解码路径的文件名。在准备完成前起拖会静默取消本次手势;实际准备失败才显示重试提示。受管理的 Web 拖放携带应用内令牌和指向插件所持 Blob URL 的 `DownloadURL`,不设置 `text/plain`。 ### 本地化文案 -这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 +这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 ----- @@ -147,7 +159,7 @@ kind: "package-library" | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI 转义解析(`anser`)与终端卡片渲染 | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | -| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 | +| [`src/icons/`](src/icons/) | 默认产品图标图形、其 `ICON_SYMBOLS` sprite 文档,以及引用这些 symbol 的与尺寸无关的 `Regular` 和 `Medium` 组件 | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 | | [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 | @@ -184,6 +196,8 @@ kind: "package-library" ----- +`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。 + ## 模型体验 @@ -200,15 +214,20 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 +- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 +- **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 +- **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 +- **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。 +- **紧跟 `\verb` 分隔符的围栏属于内容**:解析器只读取围栏之前的六个字符,因此紧跟 `\verb` 或 `\verb*` 的 `$` 是该命令的分隔符而不是闭合围栏,内容以 `\verb` 结尾的区域(`\verb**\verb*$$`)同理。未闭合区域内部的围栏(`\verb|$$`)仍会在该处闭合块,因为区域内容不被解析。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色、回车、退格、行内擦除、制表位与字符宽度会被遵循;绝对光标定位、清屏与备用屏幕序列会被剥离。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 09dd9df339..c186e06dc8 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -34,6 +34,7 @@ "katex": "^0.18.7", "micromark-core-commonmark": "^2.0.3", "micromark-util-character": "^2.1.1", + "micromark-util-decode-string": "^2.0.1", "micromark-util-classify-character": "^2.0.1", "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", @@ -48,7 +49,8 @@ "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", - "simple-icons": "16.31.0" + "simple-icons": "16.31.0", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css b/packages/client/ui-primitives/src/AnimatedRows.module.css similarity index 100% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css rename to packages/client/ui-primitives/src/AnimatedRows.module.css diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx b/packages/client/ui-primitives/src/AnimatedRows.tsx similarity index 92% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx rename to packages/client/ui-primitives/src/AnimatedRows.tsx index 12030ab6f2..fb4a8fac68 100644 --- a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -1,14 +1,16 @@ -/** React-commit-driven movement and entry/exit fades for the sidebar's keyed rows. */ -import { Component, createRef, type ReactNode } from 'react' +/** React-commit-driven movement and entry/exit fades for keyed rows. */ +import { Component, createRef, type AriaRole, type ReactNode } from 'react' import css from './AnimatedRows.module.css' const ROW_FADE_MS = 100 const ROW_GLIDE_MS = 200 -interface AnimatedRowsProps { +/** Keyed row content, list semantics, and animation readiness. */ +export interface AnimatedRowsProps { children: ReactNode className: string - label: string + role?: AriaRole + label?: string /** Unique DOM-order keys matching the rendered data-row-key attributes. */ rowKeys: readonly string[] ready: boolean @@ -38,7 +40,7 @@ function intersects(row: DOMRect, viewport: DOMRect): boolean { } /** - * Animates keyed sidebar rows only when their rendered membership or order changes. + * Animates keyed rows only when their rendered membership or order changes. * Motion starts after the first pointer or keyboard input inside the mounted list. * The parent supplies a positioned container for the inert exit overlay. */ @@ -65,9 +67,7 @@ export class AnimatedRows extends Component { const clone = row.element.cloneNode(true) as HTMLElement clone.removeAttribute('data-row-key') clone.inert = true - clone.style.setProperty( - '--dsh-workspace-indent', getComputedStyle(row.element).getPropertyValue('--dsh-workspace-indent'), - ) + clone.style.paddingInlineStart = getComputedStyle(row.element).paddingInlineStart removed.set(key, { ...row, element: clone }) } return { positions, removed } @@ -170,7 +170,7 @@ export class AnimatedRows extends Component {
{ this.armed = true }} onKeyDownCapture={() => { this.armed = true }} diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index cf0560a80f..156f5381c0 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -38,12 +38,14 @@ color: var(--dsw-alias-label-primary-foreground); } -.primary:hover:not(:disabled) { - background: var(--dsw-alias-button-primary-hover); -} +@media (hover: hover) { + .primary:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); + } -.ghost:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); + .ghost:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } .ghost:active:not(:disabled) { @@ -57,16 +59,20 @@ box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsh-button-outline-color, var(--dsw-alias-border-l3)); } -.outline:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .outline:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } -.toolbar:hover:not(:disabled) { - background: var(--dsw-alias-button-tool-bar-hover); +@media (hover: hover) { + .toolbar:hover:not(:disabled) { + background: var(--dsw-alias-button-tool-bar-hover); + } } .icon { diff --git a/packages/client/ui-primitives/src/CodeCard.module.css b/packages/client/ui-primitives/src/CodeCard.module.css index 359002d14f..9d3d1f2ebe 100644 --- a/packages/client/ui-primitives/src/CodeCard.module.css +++ b/packages/client/ui-primitives/src/CodeCard.module.css @@ -67,8 +67,10 @@ cursor: pointer; } -.action:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .action:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .action:focus-visible { diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index a4ece281a3..3814a4ae7d 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -39,12 +39,14 @@ cursor: pointer; } -.warning:hover { - background: color-mix( - in srgb, - var(--dsw-alias-state-warn-tertiary), - var(--dsw-alias-state-warn-primary) 6% - ); +@media (hover: hover) { + .warning:hover { + background: color-mix( + in srgb, + var(--dsw-alias-state-warn-tertiary), + var(--dsw-alias-state-warn-primary) 6% + ); + } } .warning:active { diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index 3b8c641d8b..d35bc41ac0 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -75,6 +75,8 @@ text-align: left; } -.expand:hover { - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .expand:hover { + color: var(--dsw-alias-label-secondary); + } } diff --git a/packages/client/ui-primitives/src/DisclosureRow.module.css b/packages/client/ui-primitives/src/DisclosureRow.module.css index 25934c11f4..f31b711728 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.module.css +++ b/packages/client/ui-primitives/src/DisclosureRow.module.css @@ -43,8 +43,10 @@ cursor: pointer; } -.row:hover { - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .row:hover { + color: var(--dsw-alias-label-secondary); + } } .leading { @@ -89,12 +91,14 @@ button.leading { transition: opacity 100ms ease; } -.row:hover .iconIdle { - opacity: 0; -} +@media (hover: hover) { + .row:hover .iconIdle { + opacity: 0; + } -.row:hover .chevronHover { - opacity: 1; + .row:hover .chevronHover { + opacity: 1; + } } .title { diff --git a/packages/client/ui-primitives/src/DraggableImage.tsx b/packages/client/ui-primitives/src/DraggableImage.tsx new file mode 100644 index 0000000000..a7ce07efce --- /dev/null +++ b/packages/client/ui-primitives/src/DraggableImage.tsx @@ -0,0 +1,26 @@ +/** Original-image drag gestures owned by a presentation primitive. */ +import { forwardRef, type ImgHTMLAttributes } from 'react' +import { useImageDrag } from './image-drag.ts' +import type { FileDragActions } from './file-drag-actions.ts' + +/** Image presentation plus original content and explicit file preparation callbacks. */ +export interface DraggableImageProps extends Omit, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerLeave' | 'onPointerDown' | 'onPointerUp' | 'onClickCapture'> { + readonly fileDrag: FileDragActions + readonly src: string + readonly name: string + readonly resourceAddress?: string | undefined + readonly original?: File | undefined +} + +/** + * Render an image with original-file dragging; using explicit application-owned callbacks. + * @param props - image attributes, original filename and file preparation callbacks. + * @param ref - image element for measurement and pointer-mode bindings. + * @returns a draggable image that suppresses only the mouse click following its drag. + */ +export const DraggableImage = forwardRef(function DraggableImage( + { fileDrag, src, name, original, resourceAddress, ...props }, ref, +) { + const drag = useImageDrag(fileDrag, src, name, original, resourceAddress) + return +}) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..5679f43bd3 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,16 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.fitContent { + width: max-content; +} + +.portal { + display: contents; +} + +.bridge { + position: fixed; + z-index: 100; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 3b3ab47656..f11c6bad30 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -16,16 +16,29 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface CardPosition { + left: number + top: number + width?: number + maxWidth?: number + maxHeight?: number + bridge?: { left: number; top: number; width: number; height: number } +} + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. + * Touch entry does not open a preview or intercept the anchor's action. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, the frame-top clearance (capped below an anchor inside the top strip), + * and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. + * Its hover region connects the anchor and preview across the visual gap. + * @param props.fitContent - size an inline card to its content within the usual width cap. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. @@ -37,11 +50,12 @@ const VIEWPORT_MARGIN = 8 */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, fitContent = false, }: { anchor: ReactNode /** Inline media preview using the shared menu material and keyboard focus. */ inline?: boolean + fitContent?: boolean content: ReactNode openDelayMs?: number disabled?: boolean @@ -55,6 +69,7 @@ export function HoverCard({ })) { const rootRef = useRef(null) const cardRef = useRef(null) + const portalRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -64,7 +79,7 @@ export function HoverCard({ const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -106,8 +121,8 @@ export function HoverCard({ const groupVisible = open && positioned useLayoutEffect(() => { if (!groupVisible || group === null) return - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible card's ref is attached before layout effects. - group.shown(groupOwner, dismissImmediately, cardRef.current!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible portal's ref is attached before layout effects. + group.shown(groupOwner, dismissImmediately, portalRef.current!) return () => { group.release(groupOwner) } }, [group, groupOwner, groupVisible, dismissImmediately]) @@ -164,7 +179,7 @@ export function HoverCard({ if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) @@ -179,17 +194,25 @@ export function HoverCard({ } if (inline) { const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below const maxHeight = onTop ? above : below + const left = Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)) + const top = onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop + const bridgeTop = onTop ? top + Math.min(height, maxHeight) : r.bottom + const bridgeLeft = Math.min(r.left, left) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), - top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, - width, maxHeight, + left, top, ...fitContent ? {} : { width }, maxWidth, maxHeight, + bridge: { + left: bridgeLeft, top: bridgeTop, + width: Math.max(r.right, left + width) - bridgeLeft, + height: Math.max(0, (onTop ? r.top : top) - bridgeTop), + }, }) return } @@ -208,7 +231,7 @@ export function HoverCard({ window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, inline, fitContent]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The @@ -239,7 +262,7 @@ export function HoverCard({ const copyable = copyText !== undefined const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. - if (cardRef.current?.contains(event.target as Node)) return + if (portalRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() close() @@ -247,10 +270,11 @@ export function HoverCard({ const card = open && pos !== null && (
{ - if (disabled || (inline && event.pointerType === 'touch')) return + if (disabled || event.pointerType === 'touch') return + if (event.pointerType === 'mouse' && !window.matchMedia('(any-hover: hover)').matches) return // Coming back inside during the grace (the gap, or the card itself) // keeps the current card rather than restarting the dwell. cancelClose() @@ -310,7 +335,12 @@ export function HoverCard({ > {anchor} {open && copyable && {copied ? copiedLabel : ''}} - {card !== false && createPortal(card, document.body)} + {card !== false && createPortal( + + {pos.bridge !== undefined && } + {card} + , document.body, + )} ) } diff --git a/packages/client/ui-primitives/src/HoverCardGroup.tsx b/packages/client/ui-primitives/src/HoverCardGroup.tsx index 163202d813..a218110e1c 100644 --- a/packages/client/ui-primitives/src/HoverCardGroup.tsx +++ b/packages/client/ui-primitives/src/HoverCardGroup.tsx @@ -16,9 +16,14 @@ export const HoverCardGroupContext = createContext(null) * Share first-display dwell across a list and its active portal, including the card's existing pointer grace. * @param props.children - list contents, including opted-in HoverCards. * @param props.className - classes for the physical list container. + * @param props.as - physical container tag; use span to preserve inline text layout. * @returns the list container and its private hover coordinator. */ -export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) { +export function HoverCardGroup({ children, className, as: Container = 'div' }: { + children: ReactNode + className?: string | undefined + as?: 'div' | 'span' +}) { const [group] = useState(() => { let pointer: 'outside' | 'list' | 'card' = 'outside' let warm = false @@ -52,7 +57,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }) return ( -
{ const overCard = group.containsCard(event.target) @@ -66,7 +71,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }} > {children} -
+
) } diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index f68c107000..805d2b4d65 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -43,11 +43,16 @@ padding: 6px 8px 8px 14px; } -.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover), .expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) { background: var(--json-tree-hover); } +@media (hover: hover) { + .expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover) { + background: var(--json-tree-hover); + } +} + .expandedTopLevelContainer { padding: 0; } @@ -75,7 +80,6 @@ white-space: pre-wrap; } -.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, .row:not(.topLevelBracket)[data-json-copy-active]::after, .row:has(> .expander:focus-visible)::after { position: absolute; @@ -89,6 +93,20 @@ pointer-events: none; } +@media (hover: hover) { + .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after { + position: absolute; + z-index: 0; + top: 0; + right: 0; + left: 0; + height: var(--json-tree-row-height, 16px); + background: var(--json-tree-hover); + content: ''; + pointer-events: none; + } +} + .row > span:not(.expander, .copySlot) { position: relative; z-index: 1; @@ -182,8 +200,10 @@ pointer-events: auto; } -.stringToggle:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .stringToggle:hover { + color: var(--dsw-alias-label-primary); + } } .stringToggle:focus-visible { @@ -254,13 +274,19 @@ padding-right: 24px; } -.row:hover:not(:has(.row:hover)) > .copySlot, .row[data-json-copy-active] > .copySlot, .copySlot:focus-within { opacity: 1; pointer-events: auto; } +@media (hover: hover) { + .row:hover:not(:has(.row:hover)) > .copySlot { + opacity: 1; + pointer-events: auto; + } +} + .actionButton { box-sizing: border-box; display: inline-flex; @@ -277,8 +303,10 @@ cursor: pointer; } -.actionButton:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .actionButton:hover { + color: var(--dsw-alias-label-primary); + } } .actionButton[aria-pressed='true'] { @@ -326,8 +354,10 @@ transform: rotate(90deg) scale(0.75); } -.expander:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .expander:hover { + color: var(--dsw-alias-label-primary); + } } .expander:focus-visible { @@ -343,3 +373,9 @@ .collapsedContent::after { content: '…'; } + +@media (hover: none) { + .copySlot { opacity: 1; pointer-events: auto; } + + .row:has(> .copySlot) { padding-right: 24px; } +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index a6d6714783..1fd68864bb 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -109,8 +109,10 @@ text-align: left; } -.item:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .item:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } /* Arrow navigation moves real focus, so the row the keyboard is on carries the @@ -206,6 +208,12 @@ line-height: 16px; } +@media (pointer: coarse) { + :global(html:not([data-input-modality='keyboard'])) .shortcut { + display: none; + } +} + .check { flex: none; color: var(--dsw-alias-label-primary); @@ -231,8 +239,10 @@ color: var(--dsw-alias-state-error-primary); } -.danger:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); +@media (hover: hover) { + .danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + } } .danger:focus-visible:not(:disabled) { diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..68b3a5766e 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -51,6 +51,8 @@ export interface MenuItemButtonProps { shortcut?: MenuItem['shortcut'] /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Whether this row identifies the currently displayed page or view. */ + current?: boolean /** Whether the row cannot be activated. */ disabled?: boolean /** Destructive row: error-colored text/icon and danger hover fill. */ @@ -75,6 +77,7 @@ export interface MenuItemButtonProps { * @param props.children - visible row label. * @param props.shortcut - effective key labels and accessible combination. * @param props.icon - optional leading icon. + * @param props.current - whether the row identifies the currently displayed page or view. * @param props.disabled - whether the row cannot be activated. * @param props.danger - whether to use the destructive row colors. * @param props.separatorBefore - whether this row starts a new group (hairline above it). @@ -82,7 +85,7 @@ export interface MenuItemButtonProps { * @returns one menu-item row. */ export function MenuItemButton({ - children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect, + children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect, }: MenuItemButtonProps) { return (
@@ -93,6 +96,7 @@ export function MenuItemButton({ className={clsx(css.item, danger && css.danger)} disabled={disabled} aria-keyshortcuts={shortcut?.aria} + aria-current={current === true ? 'page' : undefined} onClick={onSelect} > {icon !== undefined && {icon}} @@ -255,6 +259,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (r === null) return const MARGIN = 12 + const GAP = 4 const vw = window.innerWidth const vh = window.innerHeight const listEl = listRef.current @@ -268,18 +273,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI let x: number let y: number if (side === 'right') { - x = r.right + 4 + x = r.right + GAP y = r.top } else if (align === 'start') { x = r.left - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } else { x = r.right - lw - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index be4b832d23..6be4e62b0a 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -98,8 +98,10 @@ color: var(--dsw-alias-label-secondary); } -.close:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .close:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* Description and body share the 332px content column (24px side pads). */ diff --git a/packages/client/ui-primitives/src/Pill.module.css b/packages/client/ui-primitives/src/Pill.module.css index ed5aa76022..7295f702b8 100644 --- a/packages/client/ui-primitives/src/Pill.module.css +++ b/packages/client/ui-primitives/src/Pill.module.css @@ -17,8 +17,10 @@ cursor: pointer; } -.interactive:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .interactive:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .active { diff --git a/packages/client/ui-primitives/src/QuotePreview.module.css b/packages/client/ui-primitives/src/QuotePreview.module.css new file mode 100644 index 0000000000..1418d8393c --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.module.css @@ -0,0 +1,101 @@ +.quote { + display: inline-flex; + align-items: baseline; + gap: 4px; + box-sizing: border-box; + max-width: min(80%, 300px); + margin: 0; + padding: 0; + vertical-align: baseline; + color: var(--dsw-alias-state-business-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(20px + var(--dsh-content-font-delta, 0px)); +} + +.popoverText { + display: block; + padding: 8px 10px; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.source { + min-width: 0; + padding: 0; + border: none; + outline: none; + background: none; + color: inherit; + font: inherit; + line-height: inherit; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marker, +.remove { + display: grid; + place-items: center; + align-self: center; + flex: none; + width: 16px; + height: 16px; + padding: 0; + color: inherit; + user-select: none; +} + +.marker { + pointer-events: none; +} + +.remove { + border: none; + border-radius: 50%; + corner-shape: round; + background: transparent; + cursor: pointer; +} + +.quoteMark, +.closeMark { + grid-area: 1 / 1; + transition: opacity 120ms ease; +} + +.closeMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .quoteMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .closeMark { + opacity: 1; +} + +@media (hover: hover) { + .quote[data-removable]:hover .quoteMark { + opacity: 0; + } + + .quote[data-removable]:hover .closeMark { + opacity: 1; + } +} + +@media (hover: none) { + .quote[data-removable] .quoteMark { + opacity: 0; + } + + .quote[data-removable] .closeMark { + opacity: 1; + } +} diff --git a/packages/client/ui-primitives/src/QuotePreview.tsx b/packages/client/ui-primitives/src/QuotePreview.tsx new file mode 100644 index 0000000000..4cc56d2cec --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.tsx @@ -0,0 +1,103 @@ +/** Shared compact presentation for draft and sent text quotes. */ + +import { useLayoutEffect, useRef, useState } from 'react' +import type { JSX, MouseEvent as ReactMouseEvent } from 'react' +import { IconCloseOutlineRegular } from './icons/index.tsx' +import { HoverCard } from './HoverCard.tsx' +import css from './QuotePreview.module.css' + +function QuoteMarkIcon({ className }: { readonly className?: string | undefined }) { + return ( + + + + + + + ) +} + +/** Optional whole-quote removal control supplied by an editable owner. */ +export interface QuotePreviewRemoval { + /** Localized accessible name for the removal button. */ + readonly label: string + /** Remove the complete owning quote node. */ + readonly onRemove: () => void +} + +/** Presentation inputs for one compact quote excerpt. */ +export interface QuotePreviewProps { + /** Exact selected text rendered inline and exposed in a popover while clipped. */ + readonly text: string + /** Composer-only removal action; omit for inert sent quotes. */ + readonly removal?: QuotePreviewRemoval | undefined +} + +/** + * Render one width-capped quote preview shared by editable drafts and sent messages. + * Clipped excerpts open a content-sized, scrollable popover that fits within the viewport. + * @param props - selected text and optional localized removal action. + * @returns a non-navigating quote preview with a full-text popover only while clipped. + */ +export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element { + const sourceRef = useRef(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const source = sourceRef.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (source === null) return + const measure = () => { + const clipped = source.scrollWidth > source.clientWidth + setTruncated(clipped) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(source) + return () => { observer.disconnect() } + }, [text]) + const remove = (event: ReactMouseEvent): void => { + event.preventDefault() + event.stopPropagation() + removal?.onRemove() + } + const preview = ( + + {removal === undefined + ? ( + + + + ) + : ( + + )} + {text} + + ) + return ( +