From 90af3110b722a83b0815450c83f47154ce4b5422 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 21:05:34 +0800 Subject: [PATCH 1/4] feat(web): host plugin configuration on the Plugins page The Plugins page declares three slots as children of its main entry: `plugins.item` lists an official plugin's page in the Official group, `plugins.bundle.config` renders a bundle's own form on its page, and `plugins.row.config` gives a row a configure control opening the row's page. Every entry renders in the `summary` and `page` views the page passes as owner props; the page draws the title, icon, and crumb and projects the ledgers beside its store, naming no configurable plugin. `ui-settings-plugins` registers its four host-plane pages into `plugins.item` while the Host serves their namespaces and withdraws them when it stops; the forms keep edits until a save and drop them on leaving the page, so the discard control and the unsaved marker are gone. The Settings section becomes the Built-in plugins shell around the inventory tab, and `settings.plugin.item` is retired. The Official group opens with the optional bundles the installation ships, tagged Beta where the feature is one and without an official tag, followed by the configuration pages. Auto review leaves the optional bundles and the CLI's dependencies; the install guide names it as its example spec. The version tag is a capsule like the other tags. The `plugin-config` web lane drives the pages through the real wire and opens a community fixture's row page after switching its bundle on; the settings lanes re-record the renamed navigation entry. --- ...onfiguration-on-the-plugins-page.i18n.yaml | 6 + ...lugin-configuration-on-the-plugins-page.md | 39 +++ ...in-configuration-on-the-plugins-page.zh.md | 39 +++ ...6-09-15-shipped-optional-bundles.i18n.yaml | 4 +- .../2026-09-15-shipped-optional-bundles.md | 4 +- .../2026-09-15-shipped-optional-bundles.zh.md | 4 +- apps/cli/package.json | 1 - apps/web/tests/client-plugin-live.e2e.ts | 3 +- .../created.expected.md | 4 +- .../damaged.expected.md | 4 +- .../section.expected.md | 4 +- .../cards.expected.md | 4 +- .../stored-error.expected.md | 4 +- .../catalog-inputs.expected.md | 4 +- .../models-settings/configured.expected.md | 4 +- .../models-settings/declared-edit.expected.md | 4 +- .../models-settings/declared.expected.md | 4 +- .../models-settings/empty.expected.md | 4 +- .../default-models.expected.md | 4 +- .../models.expected.md | 4 +- .../dismissed.expected.md | 4 +- .../plugin-config/official.expected.md | 35 +++ .../expected/plugin-config/row.expected.md | 11 + .../plugin-config/section.expected.md | 45 --- .../cancelled.expected.md | 2 +- .../plugin-manager/live-enabled.expected.md | 30 +- .../plugin-manager/manager.expected.md | 30 +- .../settings-chrome/dialog-en.expected.md | 4 +- .../settings-chrome/dialog.expected.md | 4 +- .../plugins/fixture-live-client/client.js | 20 +- apps/web/tests/plugin-config.e2e.ts | 209 ++++++++------ apps/web/tests/plugin-manager.e2e.ts | 22 +- apps/web/tests/settings-chrome.e2e.ts | 24 +- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 4 +- docs/subsystems/slots.zh.md | 4 +- packages/boot/app-boot/src/profile.ts | 1 - .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 27 +- .../client/ui-plugin-manager/README.zh.md | 27 +- .../src/client/PluginManagerPage.module.css | 27 +- .../src/client/PluginManagerPage.tsx | 267 ++++++++++++++---- .../src/client/config-ledger.ts | 77 +++++ .../ui-plugin-manager/src/client/index.ts | 20 +- .../ui-plugin-manager/src/client/locales.ts | 34 ++- .../src/client/manager-store.ts | 11 +- .../src/client/presentation.ts | 21 +- .../src/client/slot-contract.ts | 47 +++ .../tests/browser-plugin.client.spec.tsx | 5 + .../tests/components.client.spec.tsx | 90 +++++- .../tests/config-ledger.client.spec.ts | 103 +++++++ .../tests/manager-store.client.spec.ts | 10 +- .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 40 +-- .../client/ui-settings-plugins/README.zh.md | 38 +-- .../client/ui-settings-plugins/package.json | 7 +- .../src/client/AgentLoopCard.tsx | 23 +- .../src/client/BashCard.tsx | 23 +- .../src/client/ConfigurablePluginsTab.tsx | 43 --- .../src/client/PluginCard.module.css | 150 ---------- .../src/client/PluginCard.tsx | 110 -------- .../src/client/PluginConfigForm.module.css | 53 ++++ .../src/client/PluginConfigForm.tsx | 61 ++++ .../src/client/PluginsSettingsSection.tsx | 2 +- .../src/client/SubagentModelSelectionCard.tsx | 23 +- .../src/client/WebSearchCard.tsx | 27 +- .../ui-settings-plugins/src/client/index.ts | 127 ++++----- .../ui-settings-plugins/src/client/locales.ts | 36 +-- .../src/client/slot-contract.ts | 27 -- .../src/client/tab-store.ts | 102 ------- .../tests/apply.client.spec.ts | 80 +++--- .../tests/section.client.spec.tsx | 195 +++++-------- .../tests/stores.client.spec.ts | 144 ---------- .../client/ui-settings-plugins/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 163 ++++++++--- pnpm-lock.yaml | 6 +- 76 files changed, 1566 insertions(+), 1292 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md create mode 100644 apps/web/tests/expected/plugin-config/official.expected.md create mode 100644 apps/web/tests/expected/plugin-config/row.expected.md delete mode 100644 apps/web/tests/expected/plugin-config/section.expected.md create mode 100644 packages/client/ui-plugin-manager/src/client/config-ledger.ts create mode 100644 packages/client/ui-plugin-manager/src/client/slot-contract.ts create mode 100644 packages/client/ui-plugin-manager/tests/config-ledger.client.spec.ts delete mode 100644 packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx delete mode 100644 packages/client/ui-settings-plugins/src/client/PluginCard.module.css delete mode 100644 packages/client/ui-settings-plugins/src/client/PluginCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css create mode 100644 packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx delete mode 100644 packages/client/ui-settings-plugins/src/client/slot-contract.ts delete mode 100644 packages/client/ui-settings-plugins/src/client/tab-store.ts diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml new file mode 100644 index 0000000000..5c0a5e7d59 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +2026-09-16-plugin-configuration-on-the-plugins-page.md: 99694a278c7a443dd194c3d749b065ad5766b38b +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 29dc678024c0292ed6837c67e8fdcc09763d67be diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md new file mode 100644 index 0000000000..99694a278c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -0,0 +1,39 @@ +# Agent Note: Plugin configuration on the Plugins page + +Status: implemented + +English | [中文](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) + +## Problem + +A plugin's settings lived in Settings, on the Plugins section's configuration tab: four collapsible cards, one per host-plane namespace, beside a read-only inventory tab. The sidebar's Plugins page listed and switched bundles but could not open a plugin's settings, so two surfaces split one object between them, and a bundle installed from outside the repository had no place for a form of its own at all. + +## Decision + +**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. + +**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema. + +**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence. + +**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. + +**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example. + +## Consequences + +- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the bundle is on. +- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. +- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. + +## Alternatives considered + +**Registration metadata on the slot entry.** A slot-declared `meta` share (description, bundle, row) would let one `plugins.item` slot carry every case, at the cost of a new concept in `ui-slots`; the three slots say the same with the API that exists. + +**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor. + +**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page. + +## Testing + +`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group. diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md new file mode 100644 index 0000000000..29dc678024 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -0,0 +1,39 @@ +# Agent Note:插件页上的插件配置 + +状态:已实现 + +[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文 + +## 问题 + +插件的设置原本放在设置里、插件分区的配置标签页上:四张可折叠卡片,每个宿主平面命名空间一张,旁边是只读的清单标签页。侧栏的插件页能列出并启停组合包,却打不开插件的设置,于是同一个对象被两个界面各持一半;仓库之外安装的组合包更是完全没有地方放自己的表单。 + +## 决策 + +**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。 + +**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。 + +**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。 + +**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 + +**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。 + +## 后果 + +- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在组合包开启期间存在。 +- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 +- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 + +## 考虑过的替代方案 + +**slot 条目上的注册元数据。** 由 slot 声明的 `meta` 份额(描述、组合包、行)能让单个 `plugins.item` slot 覆盖所有情况,代价是 `ui-slots` 多一个概念;三个 slot 用现有 API 表达了同样的信息。 + +**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。 + +**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。 + +## 测试 + +`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index bd8082bc65..2b32ebfee1 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md -2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4 -2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4 +2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09 +2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 6de3eb7d39..40ef69d388 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -10,11 +10,11 @@ The Web plugin page manages only the bundles a person installed into the profile ## Decision -The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles. +The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page opens its Official group with the optional bundles, tagged beta where the feature is one, ahead of the profile's own installed bundles. Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`. +Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 05c23c7c0c..adf2b979b9 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -10,11 +10,11 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 ## 决策 -启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。 +启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页以可选组合包开启「官方」分组,属于 beta 功能的带 beta 标签,排在 profile 自己已安装的组合包之前。 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。 +Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 ## 考虑过的替代方案 diff --git a/apps/cli/package.json b/apps/cli/package.json index 82383295bc..5204f55a29 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,7 +49,6 @@ "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", - "@deepseek-ai/dsh-experimental-auto-review": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index fe5751ca11..3b321032de 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -15,8 +15,7 @@ async function openInventory(page: Page, url: string) { await page.goto(url, { waitUntil: 'load' }) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '插件', exact: true }).click() - await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() + await dialog.getByRole('button', { name: '内置插件', exact: true }).click() await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor() return dialog } diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index 8a007df052..e443599032 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index b7a483a23c..f06fa3d070 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index dd2f9af6aa..358f72e593 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 3f5edb5074..15732b9c03 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 8a0a1900a3..75f37005ed 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index 420a461512..db8230f9d7 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index aabe6c1531..1166b2cd12 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 3d24c6cf77..18a38f9843 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index 0b18b5e31a..97173d9c20 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index c779686fc2..b0e01b614d 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 840b1f03b6..db14de42a9 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index b8cd686df1..6ce0338ab8 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 66d4bf7c40..aa5bb4ac1f 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md new file mode 100644 index 0000000000..7e4ce64a10 --- /dev/null +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -0,0 +1,35 @@ +- heading "插件" [level=1] +- paragraph: 添加和管理插件 +- button "刷新" +- button "添加插件": + - img + - text: 添加插件 +- heading "官方" [level=3] +- text: "6" +- list: + - listitem: + - button "查看 智能体团队": 智能体团队 + - text: Beta 启用智能体团队协作与团队工具。 + - switch "启用 智能体团队" + - listitem: + - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 + - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 + - switch "启用 智能体团队 Web 界面" + - listitem: + - button "查看 终端": 终端 + - text: 限制 agent 运行的每一条命令。 + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: Agent 如何派发工具调用。 + - listitem: + - button "查看 Subagent": Subagent + - text: 控制 Agent 为 Subagent 选择模型的权限。 + - listitem: + - button "查看 网页搜索": 网页搜索 + - text: DeepSeek 搜索提供方。 +- heading "已安装" [level=3] +- text: "1" +- list: + - listitem: + - button "查看 live-client": live-client + - switch "启用 live-client" diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md new file mode 100644 index 0000000000..a68510932f --- /dev/null +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -0,0 +1,11 @@ +- button "返回 live-client": + - img + - text: live-client +- heading "fixture-live-client" [level=3] +- paragraph: + - code: "@fixture/live-client" +- paragraph: 示例配置项 +- form "动态插件配置": + - text: 问候语 + - textbox "问候语": 你好 + - button "保存" diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md deleted file mode 100644 index bf8ade1886..0000000000 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ /dev/null @@ -1,45 +0,0 @@ -- dialog "设置": - - navigation: - - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "插件": - - img - - text: 插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 - - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 - - heading "插件" [level=2] - - paragraph: 配置和查看本部署已安装的插件。 - - tablist "插件视图": - - tab "插件配置" [selected] - - tab "插件列表" - - tabpanel "插件配置": - - list: - - listitem: - - 'button "展开设置: 终端"': - - text: 终端 限制 agent 运行的每一条命令。 - - img - - listitem: - - 'button "展开设置: Agent 循环"': - - text: Agent 循环 Agent 如何派发工具调用。 - - img - - listitem: - - 'button "展开设置: Subagent"': - - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 - - img - - listitem: - - 'button "展开设置: 网页搜索"': - - text: 网页搜索 DeepSeek 搜索提供方。 - - img diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index be08c280a3..aae03c2610 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -5,7 +5,7 @@ - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - text: 包名或地址 - textbox "包名或地址": - - /placeholder: 例如 dsh-better-sidebar + - /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review - text: slow-package - button "不知道该填什么?": - img diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 0c785fc80b..d84849e623 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -4,21 +4,29 @@ - button "添加插件": - img - text: 添加插件 -- heading "内置" [level=3] -- text: "3" +- heading "官方" [level=3] +- text: "6" - list: - listitem: - - button "查看 智能体团队(实验性)": 智能体团队(实验性) - - text: 官方 启用智能体团队协作与团队工具。 - - switch "启用 智能体团队(实验性)" + - button "查看 智能体团队": 智能体团队 + - text: Beta 启用智能体团队协作与团队工具。 + - switch "启用 智能体团队" - listitem: - - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性) - - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。 - - switch "启用 智能体团队 Web 界面(实验性)" + - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 + - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 + - switch "启用 智能体团队 Web 界面" - listitem: - - button "查看 自动授权审查(实验性)": 自动授权审查(实验性) - - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - - switch "启用 自动授权审查(实验性)" + - button "查看 终端": 终端 + - text: 限制 agent 运行的每一条命令。 + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: Agent 如何派发工具调用。 + - listitem: + - button "查看 Subagent": Subagent + - text: 控制 Agent 为 Subagent 选择模型的权限。 + - listitem: + - button "查看 网页搜索": 网页搜索 + - text: DeepSeek 搜索提供方。 - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 808a71b71f..df91033b62 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -4,21 +4,29 @@ - button "添加插件": - img - text: 添加插件 -- heading "内置" [level=3] -- text: "3" +- heading "官方" [level=3] +- text: "6" - list: - listitem: - - button "查看 智能体团队(实验性)": 智能体团队(实验性) - - text: 官方 启用智能体团队协作与团队工具。 - - switch "启用 智能体团队(实验性)" + - button "查看 智能体团队": 智能体团队 + - text: Beta 启用智能体团队协作与团队工具。 + - switch "启用 智能体团队" - listitem: - - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性) - - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。 - - switch "启用 智能体团队 Web 界面(实验性)" + - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 + - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 + - switch "启用 智能体团队 Web 界面" - listitem: - - button "查看 自动授权审查(实验性)": 自动授权审查(实验性) - - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - - switch "启用 自动授权审查(实验性)" + - button "查看 终端": 终端 + - text: 限制 agent 运行的每一条命令。 + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: Agent 如何派发工具调用。 + - listitem: + - button "查看 Subagent": Subagent + - text: 控制 Agent 为 Subagent 选择模型的权限。 + - listitem: + - button "查看 网页搜索": 网页搜索 + - text: DeepSeek 搜索提供方。 - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 0ed20913fe..f481cb6ddf 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -7,9 +7,9 @@ - button "Models": - img - text: Models - - button "Plugins": + - button "Built-in plugins": - img - - text: Plugins + - text: Built-in plugins - button "Agent presets": - img - text: Agent presets diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 4ed5139aec..581273fa84 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -7,9 +7,9 @@ - button "模型": - img - text: 模型 - - button "插件": + - button "内置插件": - img - - text: 插件 + - text: 内置插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index 1365c0e3db..d870a5e52a 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -12,10 +12,28 @@ window.__ModuleLoader__.load({ apply(ctx) { const counters = document.documentElement.dataset counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1) - ctx.effect(() => ctx.locale.register('fixtureLive', { zh: { active: '动态插件已启用' }, en: { active: 'Live plugin enabled' } })) + ctx.effect(() => ctx.locale.register('fixtureLive', { + zh: { active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存' }, + en: { active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save' }, + })) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive', }, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active')))) + // The configuration of this bundle's one row, as the Plugins page renders it on the row's own page. + ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ + name: 'plugins.row.config', key: '@fixture/live-client#fixture-live-client', locale: 'fixtureLive', + }, ({ t, view }) => view === 'summary' + ? t('configSummary') + : React.createElement('form', { + 'data-live-config': '', + 'aria-label': t('configForm'), + onSubmit: (event) => { + event.preventDefault() + counters.liveSaves = String(Number(counters.liveSaves ?? 0) + 1) + }, + }, + React.createElement('label', null, t('configField'), React.createElement('input', { name: 'greeting', defaultValue: 'hello' })), + React.createElement('button', { type: 'submit' }, t('configSave'))))) ctx.effect(() => { const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) } window.addEventListener('dsh-fixture-ping', ping) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 2d1b770466..8b37dd3dfb 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -1,12 +1,13 @@ -// Web e2e scenario: the configurable tab in Plugins settings — the cards a -// deployment's exposed host-plane namespaces produce, one field edited through the real -// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset -// that layering produces. Zero model calls: everything is client state plus -// the settings document on a blank frame, so there is no fixture and a stray -// stream would fail loud on the open llm seam. +// Web e2e scenario: the configuration pages on the Plugins page — the official +// pages a deployment's exposed host-plane namespaces produce, one field edited +// through the real wire down to `$DSH_HOME/settings.yaml`, the override badge +// and reset that layering produces, and a community bundle's row configuration +// registered by its own browser half. Zero model calls: everything is client +// state plus the settings document and the profile on a blank frame, so there +// is no fixture and a stray stream would fail loud on the open llm seam. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' @@ -17,20 +18,26 @@ import { import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-config', import.meta.url)) -const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const OFFICIAL_EXPECTED = join(SNAPSHOT_DIR, 'official.expected.md') +const ROW_EXPECTED = join(SNAPSHOT_DIR, 'row.expected.md') +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) const MODE = webSnapshotMode() -describe('web e2e: plugin configuration section', () => { +describe('web e2e: plugin configuration pages', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + // The live-client fixture is a bundle with a browser half; switched on + // below, that half registers its row's configuration into the page. + scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, + }) browser = await chromium.launch() - // Chinese browser: the section asserts the localized copy the client - // derives from it, as the rest of the settings surface does. + // Chinese browser: the pages assert the localized copy the client derives + // from it, as the rest of the settings surface does. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -43,24 +50,29 @@ describe('web e2e: plugin configuration section', () => { }) /** - * Open the settings dialog on the Plugins section. The scenarios share one - * page so the settings document accumulates across them, so this leaves any - * dialog a previous scenario opened closed first — its mask would otherwise - * swallow the trigger click. + * Show the Plugins page's cards. The scenarios share one page so the + * settings document accumulates across them, so this closes any settings + * dialog a previous scenario left open and leaves whatever page it opened. */ - async function openPlugins() { + async function openPlugins(): Promise { if (await page.getByRole('dialog', { name: '设置' }).count() > 0) { await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) } - await page.getByRole('button', { name: '设置', exact: true }).click() - const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件', exact: true }).click() - await expect - .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) - .toBe('true') - return dialog + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.waitFor({ timeout: 10_000 }) + while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) { + await panel.getByRole('button', { name: /^返回/ }).first().click() + } + await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 20_000 }) + return panel + } + + /** Open one official plugin's page from its card and wait for its form. */ + async function openPage(panel: Locator, title: string): Promise { + await panel.getByRole('button', { name: `查看 ${title}`, exact: true }).click() + await panel.locator('[data-plugin-config]').waitFor({ timeout: 10_000 }) } /** The settings document as the Host has written it so far. */ @@ -68,70 +80,67 @@ describe('web e2e: plugin configuration section', () => { return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '') } - it('shows one card per exposed host-plane namespace', async () => { + it('lists one official page per exposed host-plane namespace after the official bundles', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) - const dialog = await openPlugins() + const panel = await openPlugins() - // Every card the shipped web composition exposes: the shell executor, the - // agent loop, subagent selection, and the DeepSeek search provider. - await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) - await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) - expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) - // Collapsed: a card's fields appear only once it is expanded. - expect(await dialog.getByLabel('命令超时(毫秒)').count()).toBe(0) + // Every page the shipped web composition exposes: the shell executor, the + // agent loop, subagent selection, and the DeepSeek search provider, after + // the two official bundles the installation ships switched off. + await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 }) + const official = panel.locator('[data-plugin-group="official"]') + expect(await official.locator('[data-plugin-package]').count()).toBe(2) + expect(await official.locator('[data-plugin-item]').count()).toBe(4) + for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) { + expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) + } + // A card carries the one-liner; the fields wait for the page. + expect(await official.getByText('限制 agent 运行的每一条命令。', { exact: true }).count()).toBe(1) + expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0) - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(OFFICIAL_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('persists selected adapter routes as the subagent model allowlist', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) - const dialog = await openPlugins() - await dialog.getByText('Subagent', { exact: true }).click() - const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) + const panel = await openPlugins() + await openPage(panel, 'Subagent') + const toggle = panel.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) await toggle.click() - const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' }) + const models = panel.getByRole('group', { name: 'Agent 可选择的模型' }) await models.waitFor({ timeout: 10_000 }) const firstModel = models.getByRole('checkbox').first() await firstModel.check() - await dialog.getByRole('button', { name: '保存', exact: true }).click() + const save = panel.getByRole('button', { name: '保存', exact: true }) + await save.click() - const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' }) - await expandSubagent.waitFor({ timeout: 5_000 }) await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) expect(await settingsDocument()).toContain('enabled: true') expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') - await expandSubagent.click() + // The page stays open once the save landed; a settled form offers no save to repeat. await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') - await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) - expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) + await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) await toggle.click() - await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expandSubagent.waitFor({ timeout: 5_000 }) + await save.click() await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) .toBe(true) - expect(await settingsDocument()).toContain('allowedModels:') - expect(await settingsDocument()).toContain('provider:') - expect(await settingsDocument()).toContain('model:') - await expandSubagent.click() await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) - const dialog = await openPlugins() - await dialog.getByText('终端', { exact: true }).click() + const panel = await openPlugins() + await openPage(panel, '终端') - const timeout = dialog.getByLabel('命令超时(毫秒)') + const timeout = panel.getByLabel('命令超时(毫秒)') await timeout.waitFor({ timeout: 10_000 }) // The composed default this deployment ships, before any user layer. expect(await timeout.inputValue()).toBe('60000') @@ -141,83 +150,111 @@ describe('web e2e: plugin configuration section', () => { // Nothing crosses the wire until the user saves: leaving the control is // not a decision to store the value. expect(await settingsDocument()).not.toContain('timeoutMs') - const save = dialog.getByRole('button', { name: '保存', exact: true }) + const save = panel.getByRole('button', { name: '保存', exact: true }) await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true) await save.click() await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) - const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) - await expandTerminal.waitFor({ timeout: 5_000 }) - await expandTerminal.click() // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. - await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) - expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1) + await expect.poll(() => panel.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) + expect(await panel.getByRole('button', { name: '恢复默认' }).count()).toBe(1) // A settled form offers no save to repeat. await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('drops a staged edit on discard without touching the document', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard')) - const dialog = await openPlugins() - await dialog.getByText('终端', { exact: true }).click() - const timeout = dialog.getByLabel('命令超时(毫秒)') + it('drops a staged edit when the page is left, without touching the document', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-leave')) + const panel = await openPlugins() + await openPage(panel, '终端') + const timeout = panel.getByLabel('命令超时(毫秒)') await timeout.waitFor({ timeout: 10_000 }) await timeout.fill('7000') - await dialog.getByRole('button', { name: '放弃修改' }).click() + await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 10_000 }) + await openPage(panel, '终端') - await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000') + await expect.poll(() => panel.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('12000') expect(await settingsDocument()).toContain('timeoutMs: 12000') expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('refuses to save a draft that is not a number', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid')) - const dialog = await openPlugins() - await dialog.getByText('终端', { exact: true }).click() - const timeout = dialog.getByLabel('命令超时(毫秒)') + const panel = await openPlugins() + await openPage(panel, '终端') + const timeout = panel.getByLabel('命令超时(毫秒)') await timeout.waitFor({ timeout: 10_000 }) await timeout.fill('soon') - const save = dialog.getByRole('button', { name: '保存', exact: true }) + const save = panel.getByRole('button', { name: '保存', exact: true }) await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) - expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1) - await dialog.getByRole('button', { name: '放弃修改' }).click() + expect(await panel.getByText('请填数字;留空表示使用默认值。').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('clears the field back to the composed default on reset', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset')) - const dialog = await openPlugins() - await dialog.getByText('终端', { exact: true }).click() - const timeout = dialog.getByLabel('命令超时(毫秒)') + const panel = await openPlugins() + await openPage(panel, '终端') + const timeout = panel.getByLabel('命令超时(毫秒)') await timeout.waitFor({ timeout: 10_000 }) expect(await timeout.inputValue()).toBe('12000') // The reset stages the composed default; the document still carries the // override until the save lands. - await dialog.getByRole('button', { name: '恢复默认' }).click() + await panel.getByRole('button', { name: '恢复默认' }).click() await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000') expect(await settingsDocument()).toContain('timeoutMs: 12000') - await dialog.getByRole('button', { name: '保存', exact: true }).click() + await panel.getByRole('button', { name: '保存', exact: true }).click() await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) - const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) - await expandTerminal.waitFor({ timeout: 5_000 }) - await expandTerminal.click() expect(await timeout.inputValue()).toBe('60000') - expect(await dialog.getByText('已覆盖').count()).toBe(0) + expect(await panel.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('renders a community bundle\'s row configuration, registered by its browser half, on the row\'s page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-row')) + const panel = await openPlugins() + + // Off, the bundle's browser half is not loaded and the row has no configuration to open. + await panel.getByRole('button', { name: '查看 live-client', exact: true }).click() + const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' }) + await row.waitFor({ timeout: 10_000 }) + expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0) + + // Switched on, the Host recomposes and the browser half mounts without a + // reload; its registration puts the configure control on the row. + await panel.getByRole('switch', { name: '启用 live-client' }).click() + const configure = panel.getByRole('button', { name: '配置 fixture-live-client' }) + await configure.waitFor({ timeout: 30_000 }) + await configure.click() + + const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]') + await rowPage.waitFor({ timeout: 10_000 }) + expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client') + expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1) + const form = rowPage.getByRole('form', { name: '动态插件配置' }) + await form.getByLabel('问候语').fill('你好') + await form.getByRole('button', { name: '保存' }).click() + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveSaves), { timeout: 5_000 }).toBe('1') + + const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE) + await rowPage.getByRole('button', { name: '返回 live-client' }).click() + await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 }) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md']) }) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b4ff640b0c..c8a07c1d79 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -71,10 +71,13 @@ describe('web e2e: plugin manager', () => { await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 }) const toggle = panel.getByRole('switch', { name: '启用 bundle' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') - // The profile's own group holds its one bundle; the installation's optional bundles form the built-in - // group, and its other bundles stay off the page. + // The profile's own group holds its one bundle; the installation's optional bundles open the Official + // group, followed by the official plugins that registered their configuration, and its other bundles + // stay off the page. expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1) - expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(3) + expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2) + expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) + expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2) // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) @@ -91,7 +94,7 @@ describe('web e2e: plugin manager', () => { it('updates built-in names and descriptions when the UI language changes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() - await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click() + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1) @@ -101,16 +104,19 @@ describe('web e2e: plugin manager', () => { await page.getByRole('menuitem', { name: 'English' }).click() await page.getByRole('dialog', { name: 'Settings' }).waitFor() await page.keyboard.press('Escape') - await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor() + await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) { + for (const title of ['Agent Teams', 'Agent Teams Web UI']) { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1) } expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1) - expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1) + // The official configuration pages follow the language too, from their own dictionary. + for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) { + await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() + } } finally { if (await page.locator('html').getAttribute('lang') === 'en') { if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) { @@ -122,7 +128,7 @@ describe('web e2e: plugin manager', () => { } await closeSettings() } - await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor() + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 63198bfd9d..6cbbb43700 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -97,14 +97,14 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '模型' }).click() await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() - // Plugins: the configuration tab beside the read-only Plugin list, a - // projection of the same assembled Loader tree; management lives on the - // sidebar's Plugins panel (its own scenario file drives that page over a - // profile runtime). Capture one stable shipped row rather than the whole - // inventory so adding an unrelated plugin does not rewrite this surface's golden. - await dialog.getByRole('button', { name: '插件', exact: true }).click() - await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) - await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() + // Built-in plugins: the read-only Plugin list, a projection of the same + // assembled Loader tree, shown as the section's one page; management and + // configuration live on the sidebar's Plugins panel (its own scenario files + // drive that page over a profile runtime). Capture one stable shipped row + // rather than the whole inventory so adding an unrelated plugin does not + // rewrite this surface's golden. + await dialog.getByRole('button', { name: '内置插件', exact: true }).click() + await dialog.getByRole('heading', { name: '内置插件', exact: true }).waitFor({ timeout: 10_000 }) // Both groups start collapsed; the preset group's header still carries its display-only switcher. const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' }) await presetSwitcher.waitFor({ timeout: 10_000 }) @@ -128,8 +128,9 @@ describe('web e2e: settings modal and General preferences', () => { .toBe(expectedPluginCount) expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) - expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') - expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true') + expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') + // One contribution shows as the page itself, without a tab row. + expect(await dialog.getByRole('tab').count()).toBe(0) expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, @@ -656,8 +657,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) // The plugin list resolves shipped preset names through the en // dictionaries instead of echoing the preset files' Chinese metadata. - await dialog.getByRole('button', { name: 'Plugins', exact: true }).click() - await dialog.getByRole('tab', { name: 'Plugin list', exact: true }).click() + await dialog.getByRole('button', { name: 'Built-in plugins', exact: true }).click() const presetSwitcher = dialog.getByRole('button', { name: 'Choose the agent preset to inspect' }) await presetSwitcher.waitFor({ timeout: 10_000 }) expect(await presetSwitcher.textContent()).toBe('Standard mode (default)') diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 2001ee58f7..65b61e52aa 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 7b68979eead4afe1930243b8693bee510c75ce86 -slots.zh.md: c9ce48c528781577df807ee6d38ff7227a8a8f31 +slots.md: 015ab8ee5752cbad46ea41aa4f7001e1aa9ad28f +slots.zh.md: 92ac115947eb85f4346f3fad2d61b0603343a732 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 7b68979eea..015ab8ee57 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -128,8 +128,10 @@ root │ ├─ settings.models.provider-card │ ├─ settings.models.footer │ └─ settings.plugins.tab -│ └─ settings.plugin.item ├─ main +│ ├─ plugins.item +│ ├─ plugins.bundle.config +│ ├─ plugins.row.config │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c9ce48c528..92ac115947 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -128,8 +128,10 @@ root │ ├─ settings.models.provider-card │ ├─ settings.models.footer │ └─ settings.plugins.tab -│ └─ settings.plugin.item ├─ main +│ ├─ plugins.item +│ ├─ plugins.bundle.config +│ ├─ plugins.row.config │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 4a7133ab3e..d187f3c10a 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -167,7 +167,6 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-agent-team-web-profile', - '@deepseek-ai/dsh-experimental-auto-review', ] const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 89f69b21a5..84a331f76a 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 1a3ae0f0b5e6cfca4fdbb272f84ca4b641933ba6 -README.zh.md: 0d4d1aeb98fecb24a6bc3e9d0ac84389b0fe939e +README.md: 04139101d2745aad1e82f3cbd7e507157b614177 +README.zh.md: 0f4207895b235c441b9087440bbcfaefe6477a24 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 1a3ae0f0b5..04139101d2 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -1,5 +1,5 @@ --- -description: "Manage the profile's plugin bundles and their rows from the Web sidebar." +description: "Manage the profile's plugin bundles, their rows, and the plugins' configuration from the Web sidebar." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. Global configuration remains in Settings. +Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. A plugin that registers a configuration page is edited here, on its own page; Settings keeps the read-only inventory. ## Table of Contents @@ -25,9 +25,9 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. +Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. -The built-in Agent Teams, Agent Teams Web UI, and Auto Authorization Review bundles have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description. +The Agent Teams, Agent Teams Web UI, and Auto Authorization Review packages have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description. ### Installing a bundle @@ -43,6 +43,20 @@ A bundle's page shows its full package name under the title, the spec that insta A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows show their fiber phase as the Host runs them. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. +### Configuration pages + +A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: + +```tsx ignore-check +ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ + name: 'plugins.row.config', + key: '@acme/dsh-sidebar#sidebar', + locale: 'acmeSidebar', +}, ({ t, view }) => view === 'summary' ? t('summary') : )) +``` + +The bundle's patch must declare the row under that id, and the registration exists while the bundle is on, so a bundle that is off shows no configure control. + ----- @@ -61,6 +75,10 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th `PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. +### Configuration slots + +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. + ----- @@ -73,6 +91,7 @@ These pages cover the sidebar, the Remote calls, and the Host-side manager. - [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies. - [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginManager.*` and `pluginInventory.*`. - [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives. +- [ui-settings-plugins](../ui-settings-plugins/README.md) — the official configuration pages that register into this page's slots. ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0d4d1aeb98..0f4207895b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -1,5 +1,5 @@ --- -description: "从 Web 侧栏管理 profile 的插件组合包及其行。" +description: "从 Web 侧栏管理 profile 的插件组合包、它们的行,以及插件的配置。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。 +使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。注册了配置页的插件在这里、在它自己的页面上编辑;设置里只保留只读的插件列表。 ## 目录 @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 +在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 -内置的 Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 组合包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。 +Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。 ### 安装一个组合包 @@ -43,6 +43,20 @@ kind: "package-reference" 组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 +### 配置页 + +自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: + +```tsx ignore-check +ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ + name: 'plugins.row.config', + key: '@acme/dsh-sidebar#sidebar', + locale: 'acmeSidebar', +}, ({ t, view }) => view === 'summary' ? t('summary') : )) +``` + +组合包的 patch 必须以该 id 声明这一行;注册只在组合包开启期间存在,因此关闭的组合包不显示配置控件。 + ----- @@ -61,6 +75,10 @@ kind: "package-reference" `PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 +### 配置 slot + +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。 + ----- @@ -73,6 +91,7 @@ kind: "package-reference" - [ui-sidebar](../ui-sidebar/README.zh.md)——插件入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面占用的主 slot。 - [api-remotes](../../api/remotes/README.zh.md)——`pluginManager.*` 与 `pluginInventory.*` 背后的 Remote BFF 面。 - [plugin-manager](../../boot/plugin-manager/README.zh.md)——本页驱动的 Host 侧管理器。 +- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——注册进本页 slot 的官方配置页。 ----- 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 e44219c96f..008ce5cb62 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -910,12 +910,6 @@ .versionTag { flex: none; - padding: 1px 6px; - border-radius: 6px; - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); - font-size: 12px; - line-height: 18px; font-variant-numeric: tabular-nums; } @@ -1048,6 +1042,27 @@ overflow-wrap: anywhere; } +.rowConfigure { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; + padding: 2px 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font-size: 12.5px; + line-height: 18px; + white-space: nowrap; + cursor: pointer; +} + +.rowConfigure:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + .rowState { display: inline-flex; flex: none; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 73ed864613..310fae7bfd 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1,20 +1,24 @@ /** - * Global plugin management: cards for the profile's installed bundles and for - * the official bundles the installation ships switched off, their row - * switches, the install dialog with its guide and folded pnpm output, the - * uninstall confirmation, and the toasts an action's outcome becomes. A - * bundle's page lists the rows it contributes as the Host runs them. + * Global plugin management: the Official group's cards for the bundles the + * installation ships switched off and for the official plugins that register + * their configuration, the Installed group's cards for the profile's bundles, + * their row switches, the install dialog with its guide and folded pnpm + * output, the uninstall confirmation, and the toasts an action's outcome + * becomes. A bundle's page lists the rows it contributes as the Host runs + * them; a plugin's configuration renders on its own page through the slots + * the page declares. */ import { useEffect, useId, useState, type ReactNode } from 'react' import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client' import { - Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16, IconCordisPluginOutline14, - IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, IconWarningOutline16, - Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, + Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseOutline16, + IconCordisPluginOutline14, IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, + IconWarningOutline16, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import type { PluginManagerLocaleKey } from './locales.ts' import { isInstallPending, rowKey, @@ -22,14 +26,26 @@ import { type PluginManagerFace, } from './manager-store.ts' import { managementText, noticeText, packageText, type Translate } from './presentation.ts' +import type {} from './slot-contract.ts' import css from './PluginManagerPage.module.css' /** Full component props assembled by the main slot renderer. */ export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> + & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config'> & InjectFace +/** The page's slot renderer, narrowed to the configuration slots. */ +type RenderConfig = PluginManagerPageProps['renderSlot'] + +/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */ +type View = + | { readonly kind: 'list' } + | { readonly kind: 'package'; readonly name: string } + | { readonly kind: 'item'; readonly id: string } + | { readonly kind: 'row'; readonly name: string; readonly rowId: string } + type RowPhase = NonNullable /** How long the list marks a package an install just enabled. */ @@ -76,6 +92,12 @@ interface RowToggles { readonly onSetEnabled: (row: PackageRow, enabled: boolean) => void } +/** Configuration for a pack's rows: which rows registered a page of their own, and opening it. */ +interface RowConfigure { + readonly has: (row: PackageRow) => boolean + readonly open: (row: PackageRow) => void +} + /** Rows beyond this count get a filter box above the list. */ const ROW_FILTER_THRESHOLD = 10 @@ -112,13 +134,15 @@ function rowDotState(row: PackageRow): StateDotState { /** * A pack's rows as a list in the order the pack declares them: a state dot, - * the row id, one line saying its state, and, when the pack is on, a switch. - * A pack like base carries close to a hundred rows, so a long list gets a filter. + * the row id, one line saying its state, a configure control for a row that + * registered a page, and, when the pack is on, a switch. A pack like base + * carries close to a hundred rows, so a long list gets a filter. */ -function RowsSection({ rows, t, toggle }: { +function RowsSection({ rows, t, toggle, configure }: { readonly rows: readonly PackageRow[] readonly t: Translate readonly toggle?: RowToggles | undefined + readonly configure?: RowConfigure | undefined }): ReactNode { const [filter, setFilter] = useState('') const query = filter.trim().toLowerCase() @@ -164,6 +188,14 @@ function RowsSection({ rows, t, toggle }: { {rowStateText(row, t)} + {configure?.has(row) === true + ? ( + + ) + : null} {toggle === undefined ? null : { toggle.onSetEnabled(row, enabled) }} />} @@ -213,7 +245,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { readonly onOpen: () => void readonly onSetEnabled: (enabled: boolean) => void }): ReactNode { - const { title, description } = packageText(pkg, t) + const { title, description, beta } = packageText(pkg, t) const status = packageStatus(pkg) return (
  • - {pkg.optional ? {t('statusOfficial')} : null} + {beta ? {t('statusBeta')} : null} {status === 'problem' ? {t('statusProblem')} : null}
    {description === undefined ? null : {description}} @@ -240,16 +272,106 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { ) } +/** + * One official plugin as a card that opens its page: its icon, its title from + * the registration, and the one-liner the entry renders in its summary view. + */ +function ItemCard({ item, t, onOpen, renderSlot }: { + readonly item: OfficialItem + readonly t: Translate + readonly onOpen: () => void + readonly renderSlot: RenderConfig +}): ReactNode { + return ( +
  • +
    + +
    +
    + +
    + {renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} +
    +
    +
  • + ) +} + +/** An official plugin's page: the crumb back to the cards, its icon, its title over its one-liner, and the form the entry renders. */ +function ItemDetail({ item, t, onBack, renderSlot }: { + readonly item: OfficialItem + readonly t: Translate + readonly onBack: () => void + readonly renderSlot: RenderConfig +}): ReactNode { + return ( +
    + +
    + +
    +
    +
    +

    {item.label}

    +
    +

    {renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}

    +
    +
    + {renderSlot('plugins.item', { view: 'page' }, { only: item.id })} +
    +
    + ) +} + +/** + * A row's configuration page: the crumb back to its bundle's page, the row id + * over the module it names and the entry's one-liner, and the form the entry renders. + */ +function RowDetail({ pkg, row, t, onBack, renderSlot }: { + readonly pkg: PackageView + readonly row: PackageRow + readonly t: Translate + readonly onBack: () => void + readonly renderSlot: RenderConfig +}): ReactNode { + const { title } = packageText(pkg, t) + const key = rowConfigKey(pkg.name, row.rowId) + return ( +
    + +
    + +
    +
    +
    +

    {row.rowId}

    +
    +

    {row.moduleName}

    +

    {renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

    +
    +
    + {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })} +
    +
    + ) +} + /** * One package's page: the crumb back to the list; its icon with its switch * and, for a package the profile installed, uninstall; its title beside its - * version tag, its official tag, and its problem tag; the - * package name the title stands for, which is what installs it elsewhere; - * its one-liner; the Host's problem when it reports one; and its rows with - * their switches. + * version tag, its beta tag, and its problem tag; the package name the title + * stands for, which is what installs it elsewhere; its one-liner; the Host's + * problem when it reports one; the configuration the bundle registered for + * itself; and its rows with their switches and configure controls. */ function PackageDetail({ - pkg, t, busy, rowBusy, + pkg, t, busy, rowBusy, configured, configure, renderSlot, onBack, onSetEnabled, onUninstall, onSetRowEnabled, }: { readonly pkg: PackageView @@ -257,12 +379,16 @@ function PackageDetail({ readonly busy: boolean /** Whether a row has a write in flight. */ readonly rowBusy: (row: PackageRow) => boolean + /** Whether the bundle registered a configuration of its own. */ + readonly configured: boolean + readonly configure: RowConfigure + readonly renderSlot: RenderConfig readonly onBack: () => void readonly onSetEnabled: (enabled: boolean) => void readonly onUninstall: () => void readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void }): ReactNode { - const { title, description } = packageText(pkg, t) + const { title, description, beta } = packageText(pkg, t) const status = packageStatus(pkg) return (
    @@ -294,8 +420,8 @@ function PackageDetail({

    {title}

    - {pkg.version === undefined ? null : {t('versionTag', { version: pkg.version })}} - {pkg.optional ? {t('statusOfficial')} : null} + {pkg.version === undefined ? null : {t('versionTag', { version: pkg.version })}} + {beta ? {t('statusBeta')} : null} {status === 'problem' ? {t('statusProblem')} : null}

    {pkg.name}

    @@ -304,10 +430,18 @@ function PackageDetail({ {pkg.error === undefined ? null :

    {t('reasonLabel')}: {managementText(pkg.error, t)}

    } {pkg.readOnlyReason === undefined ? null :

    {managementText({ code: pkg.readOnlyReason }, t)}

    }
    + {configured + ? ( +
    + {renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })} +
    + ) + : null} busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} + configure={configure} />
    @@ -655,12 +789,13 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: { ) } -/** Render the plugin manager: the installed bundles, the install dialog, and the confirmation. */ +/** Render the plugin manager: the official plugins and installed bundles, their pages, the install dialog, and the confirmation. */ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { - const { t, ensure } = props + const { t, ensure, renderSlot } = props const state = props.usePluginManager(snapshot => snapshot) - // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards. - const [openPackage, setOpenPackage] = useState(null) + const ledger = props.useConfigLedger(snapshot => snapshot) + // What is open; a package that leaves the list (uninstalled) drops back to the cards. + const [view, setView] = useState({ kind: 'list' }) useEffect(() => { ensure() }, [ensure]) // A package an install just enabled: scroll it into view and mark it for a moment. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -678,41 +813,54 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { // Plugins section's Plugin list tab. const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined) const mine = listed.filter(pkg => pkg.installed || !pkg.optional) - const builtin = listed.filter(pkg => pkg.optional && !pkg.installed) + const official = listed.filter(pkg => pkg.optional && !pkg.installed) const loaded = state.status === 'ready' || state.status === 'error' - const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage) + const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined + const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined + const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined + const showsCards = openPkg === undefined && openItem === undefined const setRowEnabled = (row: PackageRow, enabled: boolean): void => { /* v8 ignore next -- a row without a live entry has its switch disabled */ if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) } - // One group of cards under its heading and count; the built-in group comes first, and a group with nothing in it takes no room. - const renderGroup = (id: 'bundles' | 'builtin', heading: string, packages: readonly PackageView[]): ReactNode => packages.length === 0 + const configure = (pkg: PackageView): RowConfigure => ({ + has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), + open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId }) }, + }) + const packageCard = (pkg: PackageView): ReactNode => ( + { setView({ kind: 'package', name: pkg.name }) }} + onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + /> + ) + // The Official group: the bundles the installation ships, then the plugins that registered their configuration. + const officialCards = [ + ...official.map(packageCard), + ...ledger.items.map(item => ( + { setView({ kind: 'item', id: item.id }) }} /> + )), + ] + // One group of cards under its heading and count; the Official group comes first, and a group with nothing in it takes no room. + const renderGroup = (id: 'official' | 'bundles', heading: string, cards: readonly ReactNode[]): ReactNode => cards.length === 0 ? null : (

    {heading}

    - {packages.length} + {cards.length}
    -
      - {packages.map(pkg => ( - { setOpenPackage(pkg.name) }} - onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} - /> - ))} -
    +
      {cards}
    ) return (
    - {openPkg === undefined + {showsCards ? (
    @@ -749,27 +897,44 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onDone={props.dismissNotice} /> )} - {loaded && openPkg !== undefined + {loaded && openPkg !== undefined && openRow !== undefined + ? ( + { setView({ kind: 'package', name: openPkg.name }) }} + /> + ) + : null} + {loaded && openPkg !== undefined && openRow === undefined ? ( row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} - onBack={() => { setOpenPackage(null) }} + configured={ledger.bundles.has(openPkg.name)} + configure={configure(openPkg)} + renderSlot={renderSlot} + onBack={() => { setView({ kind: 'list' }) }} onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} onUninstall={() => { props.uninstall(openPkg.name) }} onSetRowEnabled={setRowEnabled} /> ) : null} - {loaded && openPkg === undefined - ? listed.length === 0 + {loaded && openItem !== undefined + ? { setView({ kind: 'list' }) }} /> + : null} + {loaded && showsCards + ? officialCards.length === 0 && mine.length === 0 ?

    {t('empty')}

    : ( <> - {renderGroup('builtin', t('builtinTitle'), builtin)} - {renderGroup('bundles', t('bundlesTitle'), mine)} + {renderGroup('official', t('officialTitle'), officialCards)} + {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} ) : null} diff --git a/packages/client/ui-plugin-manager/src/client/config-ledger.ts b/packages/client/ui-plugin-manager/src/client/config-ledger.ts new file mode 100644 index 0000000000..21f6d3d746 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/config-ledger.ts @@ -0,0 +1,77 @@ +/** + * Which plugins bring their own configuration to the Plugins page, read from + * the three slots the page declares: the official plugins listed beside the + * official bundles, the bundles with a form on their page, and the rows with a + * page of their own. The projection follows the slot ledgers and the active + * locale and keeps its snapshot until one of them moves. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { resolveSlotLabel, type HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './slot-contract.ts' + +/** One official plugin as the page lists it: its registration id and its title in the active locale. */ +export interface OfficialItem { + readonly id: string + readonly label: string +} + +/** + * The plugins carrying configuration: the official plugins in ledger order, + * the package names of the bundles with a page-level form, and the keys + * ({@link rowConfigKey}) of the rows with a page. + */ +export interface ConfigLedger { + readonly items: readonly OfficialItem[] + readonly bundles: ReadonlySet + readonly rows: ReadonlySet +} + +/** + * The key a row's configuration registers under. + * @param bundle - the bundle's package name. + * @param rowId - the row id the bundle's patch declares. + * @returns the `plugins.row.config` key. + */ +export function rowConfigKey(bundle: string, rowId: string): string { + return `${bundle}#${rowId}` +} + +const SLOTS = ['plugins.item', 'plugins.bundle.config', 'plugins.row.config'] as const + +/** + * Project the configuration ledgers as one observable the page binds. + * @param ctx - the page plugin's context, whose slot registry and locale the projection follows. + * @returns the ledger source; its snapshot changes only when a ledger or the locale does. + */ +export function configLedgerSource(ctx: ClientContext): HostObservable { + let versions: readonly number[] = [] + let revision = -1 + let ledger: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } + const keysOf = (name: 'plugins.bundle.config' | 'plugins.row.config'): ReadonlySet => + new Set(ctx.slots.entries(name).flatMap(entry => entry.options.key === undefined ? [] : [entry.options.key])) + return { + getSnapshot: () => { + const next = SLOTS.map(name => ctx.slots.getVersion(name)) + const current = ctx.locale.getSnapshot().revision + if (current !== revision || next.some((version, index) => version !== versions[index])) { + versions = next + revision = current + ledger = { + items: ctx.slots.entries('plugins.item').map(entry => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: entry.options.id ?? '', + label: resolveSlotLabel(entry.options.label) ?? '', + })), + bundles: keysOf('plugins.bundle.config'), + rows: keysOf('plugins.row.config'), + } + } + return ledger + }, + subscribe: (listener) => { + const offs = [...SLOTS.map(name => ctx.slots.subscribe(name, listener)), ctx.locale.subscribe(listener)] + return () => { for (const off of offs) off() } + }, + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 7b7e9cc5e7..1e2c9a60b1 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -3,7 +3,8 @@ * management page it opens in the main column. The page installs, enables, * disables, and removes the bundles of the Host's profile through the * `pluginManager` Remote and switches their rows in the profile's user layer. - * Configuring a plugin stays in Settings. + * A plugin that carries its own configuration renders it on this page through + * the slots the page declares (`slot-contract.ts`). */ import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -21,12 +22,16 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-plugin-manager/types' import { PluginManagerPage } from './PluginManagerPage.tsx' import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' +import { configLedgerSource } from './config-ledger.ts' import { PluginManagerController } from './manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from './locales.ts' +import type {} from './slot-contract.ts' export type { PluginManagerPageProps } from './PluginManagerPage.tsx' +export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' +export type { PluginConfigViewProps } from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -71,13 +76,20 @@ export function apply(ctx: ClientContext): void { }, 'ui-plugin-manager: host invalidations') // The page is a global panel: it belongs to the profile, not to a Session, - // and the sidebar's entry selects it. How a plugin is configured stays in - // Settings; this page is what is installed and switched on. + // 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) ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, - inject: () => controller.inject(), + inject: () => controller.inject(configLedger), + children: { + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + }, }, PluginManagerPage)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 03a411c85e..9ce810db7b 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -15,15 +15,15 @@ export const zh = { restartNotice: '更改将在下次启动生效', overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', bundlesTitle: '已安装', - builtinTitle: '内置', - builtinAgentTeamTitle: '智能体团队(实验性)', + officialTitle: '官方', + builtinAgentTeamTitle: '智能体团队', builtinAgentTeamDescription: '启用智能体团队协作与团队工具。', - builtinAgentTeamWebTitle: '智能体团队 Web 界面(实验性)', + builtinAgentTeamWebTitle: '智能体团队 Web 界面', builtinAgentTeamWebDescription: '在浏览器中查看团队成员、任务看板和成员会话。', - builtinAutoReviewTitle: '自动授权审查(实验性)', + builtinAutoReviewTitle: '自动授权审查', builtinAutoReviewDescription: '提供自动审查权限模式,由模型在每次工具调用前判断是否授权。', statusProblem: '异常', - statusOfficial: '官方', + statusBeta: 'Beta', reasonLabel: '原因', versionTag: 'v{version}', noDescription: '暂无描述。', @@ -46,19 +46,22 @@ export const zh = { openDetail: '查看 {name}', backToList: '返回插件列表', crumbRoot: '插件列表', + backToPackage: '返回 {name}', + configure: '配置', + configureRow: '配置 {name}', rowStateIdle: '未运行', uninstall: '卸载', uninstallLabel: '卸载 {name}', installTitle: '添加插件', installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。', installSpecLabel: '包名或地址', - installSpecPlaceholder: '例如 dsh-better-sidebar', + installSpecPlaceholder: '例如 @deepseek-ai/dsh-experimental-auto-review', installGuideToggle: '不知道该填什么?', installGuideHide: '收起引导', installGuideIntro: '从插件的 README 或发布页面复制以下任意一项。', installGuideIdNote: '包名即 npm 包名,常见形式为 dsh-xxx 或 @作者/插件名;插件的显示名称不是包名。', installGuideIdTitle: '包名', - installGuideIdExample: 'dsh-better-sidebar', + installGuideIdExample: '@deepseek-ai/dsh-experimental-auto-review', installGuideIdHint: 'README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。', installGuideGitTitle: 'GitHub 仓库地址', installGuideGitExample: 'https://github.com/author/dsh-plugin', @@ -173,15 +176,15 @@ export const en = { restartNotice: 'The change takes effect at the next start', overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', bundlesTitle: 'Installed', - builtinTitle: 'Built in', - builtinAgentTeamTitle: 'Experimental Agent Teams', + officialTitle: 'Official', + builtinAgentTeamTitle: 'Agent Teams', builtinAgentTeamDescription: 'Enable agent team collaboration and team tools.', - builtinAgentTeamWebTitle: 'Experimental Agent Teams Web UI', + builtinAgentTeamWebTitle: 'Agent Teams Web UI', builtinAgentTeamWebDescription: 'View team members, the task board, and teammate sessions in the browser.', - builtinAutoReviewTitle: 'Experimental Auto Authorization Review', + builtinAutoReviewTitle: 'Auto Authorization Review', builtinAutoReviewDescription: 'Add an Auto review permission mode that uses the model to assess authorization before each tool call.', statusProblem: 'Problem', - statusOfficial: 'Official', + statusBeta: 'Beta', reasonLabel: 'Reason', versionTag: 'v{version}', noDescription: 'No description.', @@ -204,19 +207,22 @@ export const en = { openDetail: 'View {name}', backToList: 'Back to plugins', crumbRoot: 'Plugins', + backToPackage: 'Back to {name}', + configure: 'Configure', + configureRow: 'Configure {name}', rowStateIdle: 'Not running', uninstall: 'Uninstall', uninstallLabel: 'Uninstall {name}', installTitle: 'Add plugin', installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.', installSpecLabel: 'Package name or address', - installSpecPlaceholder: 'for example dsh-better-sidebar', + installSpecPlaceholder: 'for example @deepseek-ai/dsh-experimental-auto-review', installGuideToggle: 'Not sure what to enter?', installGuideHide: 'Hide the guide', installGuideIntro: 'Copy any one of these from the plugin\'s README or release page.', installGuideIdNote: 'The package name is the npm package name, usually dsh-xxx or @author/plugin; the display name is not one.', installGuideIdTitle: 'Package name', - installGuideIdExample: 'dsh-better-sidebar', + installGuideIdExample: '@deepseek-ai/dsh-experimental-auto-review', installGuideIdHint: 'The part after dsh plugin add or pnpm add in the README\'s install command.', installGuideGitTitle: 'GitHub repository address', installGuideGitExample: 'https://github.com/author/dsh-plugin', 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 b00841984d..6aa6772a28 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -23,6 +23,8 @@ import type { ReadOnlyReason, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConfigLedger } from './config-ledger.ts' import { shortName } from './presentation.ts' /** The action a failed notice names. */ @@ -183,6 +185,8 @@ export interface PluginManagerFace { hooks: { /** Tab snapshot bound by the renderer as usePluginManager. */ pluginManager: SnapshotStore + /** The plugins carrying configuration, bound by the renderer as useConfigLedger. */ + configLedger: HostObservable } /** Read the Host once the tab first renders. */ ensure: () => void @@ -345,11 +349,12 @@ export class PluginManagerController { /** * Build the face the tab's slot registration injects. - * @returns the tab's snapshot source and its actions. + * @param configLedger - the projection of the plugins carrying configuration, bound beside the tab's own state. + * @returns the tab's snapshot sources and its actions. */ - inject(): PluginManagerFace { + inject(configLedger: HostObservable): PluginManagerFace { return { - hooks: { pluginManager: this.store }, + hooks: { pluginManager: this.store, configLedger }, ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, refresh: () => { void this.load() }, openInstall: () => { diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 4c1932e058..b2a50a5d72 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -8,15 +8,16 @@ import type { FailedAction, ManagerNotice, PackageView } from './manager-store.t /** The translate seat of the manager's dictionary. */ export type Translate = PropsLocale<'pluginManager'>['t'] -const BUILTIN_COPY = new Map([ +/** The official packages with copy of their own, and whether each is a beta feature the page tags as such. */ +const BUILTIN_COPY = new Map([ ['@deepseek-ai/dsh-experimental-agent-team-profile', { - title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', + title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', beta: true, }], ['@deepseek-ai/dsh-experimental-agent-team-web-profile', { - title: 'builtinAgentTeamWebTitle', description: 'builtinAgentTeamWebDescription', + title: 'builtinAgentTeamWebTitle', description: 'builtinAgentTeamWebDescription', beta: true, }], ['@deepseek-ai/dsh-experimental-auto-review', { - title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', + title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', beta: true, }], ]) @@ -67,16 +68,18 @@ export function shortName(name: string): string { } /** - * Localize known built-in packages by exact npm name at render time. + * Localize known official packages by exact npm name at render time. * @param pkg - original package identity and optional metadata description. * @param t - the manager's current translate function. - * @returns localized copy, or the package's short name and original description. + * @returns localized copy and whether the package is a beta feature, or the package's short name and original description. */ -export function packageText(pkg: Pick, t: Translate): { title: string; description: string | undefined } { +export function packageText( + pkg: Pick, t: Translate, +): { title: string; description: string | undefined; beta: boolean } { const keys = BUILTIN_COPY.get(pkg.name) return keys === undefined - ? { title: shortName(pkg.name), description: pkg.description } - : { title: t(keys.title), description: t(keys.description) } + ? { title: shortName(pkg.name), description: pkg.description, beta: false } + : { title: t(keys.title), description: t(keys.description), beta: keys.beta } } /** diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts new file mode 100644 index 0000000000..2fd0b41d40 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -0,0 +1,47 @@ +/** + * The slots the Plugins page declares for plugins that carry their own + * configuration: the official plugins listed beside the official bundles, one + * bundle's configuration on its page, and one row's configuration opened from + * that row. A registrant merges this contract with `import type` and registers + * through `ctx.slots`; it never imports this package at runtime. + * + * Every entry is rendered in two views the owner asks for: `summary` for the + * one-liner the page places under the title, `page` for the form with its own + * save control. The page draws the title, the icon, and the crumb itself. + */ + +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +/** The view the page asks a configuration entry for. */ +export interface PluginConfigViewProps { + /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */ + readonly view: 'summary' | 'page' +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * One official plugin the Plugins page lists in its Official group after + * the official bundles: `label` is the card's title and `order` its place. + * The page renders the entry as the card's one-liner (`view: 'summary'`) + * and, once the card is opened, as the body of the plugin's own page + * (`view: 'page'`). OCCUPIED by the host-plane configuration pages + * `ui-settings-plugins` ships; a bundle's configuration belongs in + * `plugins.bundle.config` or `plugins.row.config` instead. + */ + 'plugins.item': { kind: 'list'; scope: 'root'; owner: PluginConfigViewProps } + /** + * A bundle's own configuration, keyed by the bundle's package name and + * rendered on the bundle's page between its description and its rows + * (`view: 'page'` only). + */ + 'plugins.bundle.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps } + /** + * The configuration of one row a bundle declares, keyed by + * `#` with the row id as the bundle's patch declares + * it: the row on the bundle's page gains a configure control that opens + * the entry's page, headed by the row id and the entry's summary. + */ + 'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps } + } +} 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 879efbbb0b..7c36c229c5 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 @@ -76,7 +76,12 @@ describe('ui-plugin-manager browser plugin', () => { expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 }) expect(icon.locale).toBe(NS) expect(resolveSlotLabel(icon.options.label)).toBe('插件') + // The page declares the slots a plugin's configuration arrives through, and binds their projection beside its state. + expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) + expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) + expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) const face = (entry.inject as unknown as () => PluginManagerFace)() + expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() }) // A Host change before the first render is not a reason to read. b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) b.ctx.emit('connection/reset') 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 7ac5613d74..ddab1083df 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -4,8 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ReactNode } from 'react' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx' +import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' @@ -50,8 +52,14 @@ const READY: PluginManagerState = { highlight: null, } -function renderTab(state: Partial = {}) { +/** Configuration entries a test supplies: what each slot cell renders, by `:` and the view asked for. */ +type SlotBodies = Record ReactNode> + +const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } + +function renderTab(state: Partial = {}, config: Partial = {}, bodies: SlotBodies = {}) { const store = createSnapshotStore({ ...READY, ...state }) + const ledger = createSnapshotStore({ ...NO_CONFIG, ...config }) const actions = { ensure: vi.fn(), refresh: vi.fn(), @@ -75,6 +83,9 @@ function renderTab(state: Partial = {}) { t, ...actions, usePluginManager: bindSnapshotSelector(store), + useConfigLedger: bindSnapshotSelector(ledger), + renderSlot: (name: string, owner: { view: 'summary' | 'page' }, opts: { only?: string; entryKey?: string }) => + bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view) ?? null, } as unknown as PluginManagerPageProps const { rerender } = render() return { @@ -105,7 +116,7 @@ describe('PluginManagerPage', () => { expect(actions.openInstall).toHaveBeenCalledTimes(1) }) - it('lists the installed bundles as cards, the installation\'s offered ones as built in, and tags a problem the Host reports', () => { + it('lists the installed bundles as cards, the installation\'s offered ones as official, and tags a problem the Host reports', () => { const { actions } = renderTab({ packages: [ pkg({ description: 'A sidebar.' }), @@ -120,16 +131,16 @@ describe('PluginManagerPage', () => { busy: ['dsh-protected'], }) const cards = screen.getAllByRole('listitem') - // The built-in group comes first. + // The Official group comes first. expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([ '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', ]) expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem']) - // Each group heads with its title and its bare count; the built-in card carries the official tag. + // Each group heads with its title and its bare count; the official bundle carries its beta tag, no official tag. expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.builtinTitle })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.officialTitle })).toBeTruthy() expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5']) - expect(screen.getAllByText(en.statusOfficial)).toHaveLength(1) + expect(screen.getAllByText(en.statusBeta)).toHaveLength(1) // A scoped name reads without its scope and harness prefix. expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false) expect(screen.getByText('A sidebar.')).toBeTruthy() @@ -143,13 +154,13 @@ describe('PluginManagerPage', () => { expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) }) - it('opens a built-in bundle\'s page with its official tag and no uninstall, and switches it on', () => { + it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => { const { actions } = renderTab({ packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })], }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement - expect(within(detail).getByText(en.statusOfficial)).toBeTruthy() + expect(within(detail).getByText(en.statusBeta)).toBeTruthy() expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull() fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) @@ -191,6 +202,67 @@ describe('PluginManagerPage', () => { } }) + describe('configuration pages', () => { + const bodies: SlotBodies = { + 'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' :
    , + 'plugins.bundle.config:dsh-better-sidebar': view => view === 'page' ? : null, + 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' : , + } + + it('lists an official plugin after the official bundles with its summary, and opens its page', () => { + renderTab( + { packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })] }, + { items: [{ id: 'bash', label: 'Shell' }] }, + bodies, + ) + const official = document.querySelector('[data-plugin-group="official"]') as HTMLElement + expect(within(official).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-item') ?? card.getAttribute('data-plugin-package'))) + .toEqual(['@deepseek-ai/dsh-experimental-agent-team-profile', 'bash']) + expect(document.querySelector('[data-plugin-count]')?.textContent).toBe('2') + expect(within(official).getByText('Limits every command.')).toBeTruthy() + // An official plugin has no switch of its own: the Host composes it. + expect(within(official).queryByRole('switch', { name: en.enableToggle.replace('{name}', 'Shell') })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })) + const detail = document.querySelector('[data-plugin-item-detail="bash"]') as HTMLElement + expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('Shell') + expect(within(detail).getByText('Limits every command.')).toBeTruthy() + expect(within(detail).getByRole('form', { name: 'bash form' })).toBeTruthy() + fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) + expect(document.querySelector('[data-plugin-item-detail]')).toBeNull() + expect(screen.getByRole('heading', { name: en.officialTitle })).toBeTruthy() + }) + + it('counts an official plugin as content: the empty line waits for a page with nothing at all', () => { + renderTab({ packages: [] }, { items: [{ id: 'bash', label: 'Shell' }] }, bodies) + expect(screen.queryByText(en.empty)).toBeNull() + expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })).toBeTruthy() + }) + + it('renders a bundle\'s own configuration on its page, and no configure control on a row without one', () => { + renderTab({ packages: [pkg({ rows: [row()] })] }, { bundles: new Set(['dsh-better-sidebar']) }, bodies) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).getByRole('form', { name: 'sidebar form' })).toBeTruthy() + expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })).toBeNull() + }) + + it('opens a row\'s configuration page from its configure control and leads back to the bundle', () => { + const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId }) + renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'theme') })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })) + const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement + expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('sidebar') + expect(within(page).getByText('dsh-better-sidebar')).toBeTruthy() + expect(within(page).getByText('The sidebar row.')).toBeTruthy() + expect(within(page).getByRole('form', { name: 'row form' })).toBeTruthy() + fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') })) + expect(document.querySelector('[data-plugin-row-detail]')).toBeNull() + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).toBeTruthy() + }) + }) + it('preserves metadata for another scope with the same short name', () => { const name = '@acme/dsh-experimental-agent-team-profile' const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] }) @@ -261,7 +333,7 @@ describe('PluginManagerPage', () => { set({ packages: [unversioned] }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) expect(screen.getByText(en.noDescription)).toBeTruthy() - expect(document.querySelector('[data-plugin-version]')).toBeNull() + expect(screen.queryByText(en.versionTag.replace('{version}', '0.16.0'))).toBeNull() set({ packages: [] }) expect(document.querySelector('[data-plugin-detail]')).toBeNull() expect(screen.getByText(en.empty)).toBeTruthy() diff --git a/packages/client/ui-plugin-manager/tests/config-ledger.client.spec.ts b/packages/client/ui-plugin-manager/tests/config-ledger.client.spec.ts new file mode 100644 index 0000000000..5c3a873e3c --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/config-ledger.client.spec.ts @@ -0,0 +1,103 @@ +/** The projection of the plugins carrying configuration: what it reads from the ledgers and when it moves. */ + +import { describe, expect, it, vi } from 'vitest' +import { configLedgerSource, rowConfigKey } from '../src/client/config-ledger.ts' + +interface Registration { + options: { id?: string; key?: string; label?: string | (() => string) } +} + +/** A slot registry and locale stand-in: ledgers by slot, versions that move on registration, and a locale revision. */ +function bench() { + const entries: Record = { 'plugins.item': [], 'plugins.bundle.config': [], 'plugins.row.config': [] } + const versions: Record = { 'plugins.item': 0, 'plugins.bundle.config': 0, 'plugins.row.config': 0 } + const listeners = new Map void>>() + const localeListeners = new Set<() => void>() + let revision = 0 + const ctx = { + slots: { + entries: (name: string) => entries[name] ?? [], + getVersion: (name: string) => versions[name] ?? 0, + subscribe: (name: string, listener: () => void) => { + const set = listeners.get(name) ?? new Set<() => void>() + set.add(listener) + listeners.set(name, set) + return () => { set.delete(listener) } + }, + }, + locale: { + getSnapshot: () => ({ revision }), + subscribe: (listener: () => void) => { + localeListeners.add(listener) + return () => { localeListeners.delete(listener) } + }, + }, + } + return { + ctx: ctx as never, + register: (name: string, options: Registration['options']): void => { + entries[name]?.push({ options }) + versions[name] = (versions[name] ?? 0) + 1 + for (const listener of listeners.get(name) ?? []) listener() + }, + setLocale: (): void => { + revision += 1 + for (const listener of localeListeners) listener() + }, + listenerCount: (): number => [...listeners.values()].reduce((count, set) => count + set.size, 0) + localeListeners.size, + } +} + +describe('configLedgerSource', () => { + it('projects the official items in ledger order with locale-following labels, and the bundle and row keys', () => { + const b = bench() + const source = configLedgerSource(b.ctx) + expect(source.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() }) + + let title = 'Shell' + b.register('plugins.item', { id: 'bash', label: () => title }) + b.register('plugins.item', { id: 'loop', label: 'Agent loop' }) + b.register('plugins.bundle.config', { key: 'dsh-x' }) + b.register('plugins.row.config', { key: rowConfigKey('dsh-x', 'row') }) + + expect(source.getSnapshot()).toEqual({ + items: [{ id: 'bash', label: 'Shell' }, { id: 'loop', label: 'Agent loop' }], + bundles: new Set(['dsh-x']), + rows: new Set(['dsh-x#row']), + }) + // A label thunk is re-read when the locale moves, not before. + title = '终端' + expect(source.getSnapshot().items[0]?.label).toBe('Shell') + b.setLocale() + expect(source.getSnapshot().items[0]?.label).toBe('终端') + }) + + it('lists an entry without a label under an empty title and skips a keyed entry without a key', () => { + const b = bench() + const source = configLedgerSource(b.ctx) + b.register('plugins.item', { id: 'bare' }) + b.register('plugins.bundle.config', {}) + + expect(source.getSnapshot()).toEqual({ items: [{ id: 'bare', label: '' }], bundles: new Set(), rows: new Set() }) + }) + + it('keeps its snapshot until a ledger or the locale moves, and follows every source while subscribed', () => { + const b = bench() + const source = configLedgerSource(b.ctx) + const first = source.getSnapshot() + expect(source.getSnapshot()).toBe(first) + const listener = vi.fn() + const off = source.subscribe(listener) + + b.register('plugins.item', { id: 'bash', label: 'Shell' }) + expect(listener).toHaveBeenCalledTimes(1) + expect(source.getSnapshot()).not.toBe(first) + b.setLocale() + expect(listener).toHaveBeenCalledTimes(2) + + off() + expect(b.listenerCount()).toBe(0) + b.register('plugins.item', { id: 'loop', label: 'Loop' }) + expect(listener).toHaveBeenCalledTimes(2) + }) +}) 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 7dafd75d0f..7efafdb050 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 @@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest' import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConfigLedger } from '../src/client/config-ledger.ts' import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' const ROW_ENTRY = 'include:sidebar' as PluginEntryId @@ -55,6 +57,12 @@ function deferred() { return { promise, resolve } } +/** A configuration ledger with nothing registered, as the page binds it beside the store. */ +const NO_CONFIG: HostObservable = { + getSnapshot: () => ({ items: [], bundles: new Set(), rows: new Set() }), + subscribe: () => () => {}, +} + function bench(overrides: Partial>> = {}) { const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } const plugins = { @@ -70,7 +78,7 @@ function bench(overrides: Partial>> = {} } const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never const controller = new PluginManagerController(ctx) - const face = controller.inject() + const face = controller.inject(NO_CONFIG) const state = () => controller.getSnapshot() /** The request id of the run the dialog just handed to the Host. */ const started = async (): Promise => { diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 20eb4a95ae..4606cdb864 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md -README.md: b4b587e427aa23f2e8ce02706a4b87249955300d -README.zh.md: b8e7352c15bafbead42fc4b585b57de05fc3d0fa +README.md: b4f6e40a5bc91322786a9dfcb8d903dd6947c724 +README.zh.md: f4a7a8fa7fffcdfd599c8afcdd95e723253aa236 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index b4b587e427..b4f6e40a5b 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -1,5 +1,5 @@ --- -description: "Plugins settings section for the dsh web client: feature-owned tabs, the configurable host-plane plugin cards, and the settings.plugin.item extension point." +description: "Built-in plugins settings section for the dsh web client, and the official plugin configuration pages that register into the Plugins page." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use the **Plugins** settings section to configure the plugins exposed by the current deployment and to open feature-specific plugin pages. The **Plugin configuration** tab presents one expandable card for each supported plugin, shows which values the user overrode, and lets the user reset them to deployment defaults. Cards keep edits local until save. If the configuration changed after the card loaded, the save is rejected instead of overwriting the newer values. +Use the **Built-in plugins** settings section to inspect the plugins this deployment ships, and the **Official** group of the sidebar's Plugins page to configure the host-plane plugins that expose settings. Each configuration page shows which values the user overrode, lets them reset those to deployment defaults, keeps edits local until save, and drops them when the page is left. If the configuration changed after the page loaded, the save is rejected instead of overwriting the newer values. ## Table of Contents @@ -25,17 +25,17 @@ Use the **Plugins** settings section to configure the plugins exposed by the cur ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). +Open **Built-in plugins** in Settings for the read-only inventory; [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes it as the section's one tab, shown as the page itself. To configure a host-plane plugin, select **Plugins** in the sidebar: the Official group lists one card per plugin this deployment composes, in this order — the shell executor (`shell`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`) — and a card opens the plugin's page with its form. ### What appears here -The tab reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing, and a card whose namespace this deployment does not serve is never dispatched. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". +Each page registers into the Plugins page's `plugins.item` slot while the Host serves its settings namespace, so a deployment that does not compose the owning plugin shows no trace of it, and a namespace the Host starts or stops serving adds or withdraws its page on the next settings-document commit or reconnect. The card's one-liner and the page's form are one entry rendered in the two views the Plugins page asks for. ### Editing and saving -A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. +A page stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store. Leaving the page drops the drafts; there is no discard control. A failed save keeps the page as it is, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. -The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. +The Subagent page stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the page refreshes them after adapter changes, settings commits, and reconnects. ### Secret-role fields @@ -49,15 +49,15 @@ A key control starts blank, reports only whether one is configured, and writes t
    Implementation internals — click to expand -The section is one extension point and one dispatch rule: feature plugins own their cards; the tab pairs served namespaces with registered cards by slot key. +The package is one registration rule and one write path: each page is registered while its namespace is served, and saves go through the client settings scope. -### The tab extension point +### The registration rule -The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a tab stays mounted after its first selection so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it: chrome, controls, and copy. Tabs follow the contribution's `order`; cards follow registration order. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a lone contribution renders as the page itself, and a tab stays mounted after its first selection so search and the inventory snapshot survive switching. The configuration pages are `plugins.item` registrations, one per namespace, made through `ctx.slots.inject` when the shared settings mirror shows the Host serves the namespace and disposed when it stops; registration order is the page order, not the Host's description order, which follows plugin activation and can change between boots. A page owns its controls and copy; the Plugins page draws its title, icon, and crumb. ### The write path -Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the page re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
    @@ -66,14 +66,14 @@ Saving writes staged fields through the client settings scope, which fences each ## Further Exploration -These pages cover the settings base, the inventory tab, and the durable seams behind the cards. +These pages cover the Plugins page, the settings base, the inventory tab, and the durable seams behind the forms. -- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.plugins.tab` and the settings scope. -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only Plugin list tab in the same section. -- [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page for installed packages and global rows. +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page whose `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` slots host configuration pages. +- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the settings scope. +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only inventory the section shows. - [settings](../../settings/README.md) — the durable user-settings seam and its file provider. - [credentials](../../credentials/README.md) — the credential-reference seam secret fields write through. -- [ui-settings-general](../ui-settings-general/README.md) — the settings shell hosting this section. +- [ui-settings-general](../ui-settings-general/README.md) — the settings shell hosting the section. ----- @@ -91,12 +91,12 @@ None; this package neither assembles nor sends a provider request. -These limits define which plugins appear and how fresh the list is; they are current package constraints. +These limits define which plugins get a page and how fresh the group is; they are current package constraints. -- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all, so this section lists nothing for it. Editing those values remains the preset editor's job. -- **A card still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `../../../packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. -- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the tab's read joins the list on the next document commit or reconnect. -- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both. +- **Only host-plane plugins have a page** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all, so this package registers nothing for it. Editing those values remains the preset editor's job. +- **A page still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `../../../packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. +- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the mirror's read joins the Official group on the next document commit or reconnect. +- **The shell page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the page edits the same two fields on both. ### Dev Note diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index b8e7352c15..f4a7a8fa7f 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端的「插件」设置分区:功能自有的标签页、可配置宿主平面插件卡片,以及 settings.plugin.item 扩展点。" +description: "dsh Web 客户端的「内置插件」设置分区,以及注册进插件页的官方插件配置页。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用**插件**设置分区可以配置当前部署公开的插件,也可以打开插件功能自己的页面。**插件配置**标签页会为每个受支持的插件展示一张可展开卡片,标明用户覆盖过哪些值,并允许用户将它们重置为部署默认值。卡片会在本地保留修改,直到用户保存。如果配置在卡片加载后发生变化,保存会被拒绝,而不会覆盖较新的值。 +使用**内置插件**设置分区查看本部署随附的插件;使用侧栏插件页的**官方**分组配置开放了设置的宿主平面插件。每个配置页标明用户覆盖过哪些值,允许把它们重置为部署默认值,在本地保留修改直到保存,离开页面即丢弃。如果配置在页面加载后发生变化,保存会被拒绝,而不会覆盖较新的值。 ## 目录 @@ -25,17 +25,17 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent loop(智能体循环)的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的**内置插件**查看只读的插件列表;它由 [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md) 作为分区唯一的标签页贡献,直接显示为页面本身。要配置宿主平面插件,在侧栏选择**插件**:官方分组为本部署组装的每个插件列出一张卡片,顺序依次为 shell 执行器(`shell`)、agent loop 的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`),点开卡片就是该插件带表单的页面。 ### 这里会出现什么 -标签页读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染;命名空间未被本部署服务的卡片根本不会被派发。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成「本部署没有可配置的插件」。 +每个页面在 Host 服务其 settings 命名空间期间注册进插件页的 `plugins.item` slot,因此没有组装该插件的部署不会留下它的任何痕迹;Host 开始或停止服务某个命名空间时,其页面会在下一次 settings 文档提交或重连时加入或撤下。卡片上的一句话简介与页面上的表单是同一个条目按插件页索取的两种视图渲染出来的。 ### 编辑与保存 -卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。 +页面暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存。离开页面即丢弃草稿,没有放弃控件。保存失败时页面保持原样、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。值是否被接受,唯一的裁判是 Host。 -subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 +subagent 页面会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述是实时目录元数据,不会被存储;页面会在适配器变化、settings 提交与重连之后刷新它们。 ### secret 角色字段 @@ -49,15 +49,15 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
    实现细节——点击展开 -本分区是一个扩展点加一条分派规则:功能插件拥有各自的卡片;标签页按 slot 键把被服务的命名空间与已注册卡片配对。 +本包是一条注册规则加一条写入路径:每个页面在其命名空间被服务期间注册;保存经由客户端 settings scope。 -### 标签页扩展点 +### 注册规则 -本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。标签页遵循贡献的 `order`;卡片遵循注册顺序。 +本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;只有一个贡献时它直接显示为页面本身,某个标签页首次被选择后会保持挂载,因此搜索与清单快照在切换时不会丢失。配置页是 `plugins.item` 注册项,每个命名空间一个:共享的 settings 镜像表明 Host 服务该命名空间时通过 `ctx.slots.inject` 注册,停止服务时销毁;注册顺序就是页面顺序,而不是 Host 的描述顺序——后者跟随插件激活,可能在两次启动之间变化。页面拥有自己的控件与文案;插件页负责画标题、图标与面包屑。 ### 写入路径 -保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 +保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;页面会在转发来的 `credentials/reference-updated` 事件到来时重读它所关注的引用。
    @@ -66,11 +66,11 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时 ## 进一步探索 -以下页面覆盖设置底座、清单标签页与卡片背后的持久化 seam。 +以下页面覆盖插件页、设置底座、清单标签页与表单背后的持久化 seam。 -- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.plugins.tab` 与 settings scope 的领域底座。 -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——同一分区中的只读「插件列表」标签页。 -- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——管理已安装包与全局行的侧栏页面。 +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——侧栏插件页,其 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` slot 承载配置页。 +- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 settings scope 的领域底座。 +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——分区显示的只读插件列表。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [credentials](../../credentials/README.zh.md)——secret 字段写入所经的凭据引用 seam。 - [ui-settings-general](../ui-settings-general/README.zh.md)——承载本分区的设置外壳。 @@ -91,12 +91,12 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时 -这些限制定义哪些插件会出现、列表有多新鲜;它们是当前包约束。 +这些限制定义哪些插件会有页面、分组有多新鲜;它们是当前包约束。 -- **只有宿主平面的插件会出现**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 -- **卡片仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。 -- **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在标签页读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。 -- **shell 卡片跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段。 +- **只有宿主平面的插件有页面**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本包不会为它注册任何东西。编辑那些值仍是 preset 编辑器的职责。 +- **页面仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。 +- **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在镜像读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入官方分组。 +- **shell 页面跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管页面在两者下编辑的都是同样两个字段。 ### 开发备注 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 64d594d376..0639c80393 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -49,13 +49,14 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx b/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx index cb310af0cd..02c702cb9e 100644 --- a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx @@ -1,30 +1,29 @@ -/** The agent loop's card: how many tool calls one step may run at once. */ +/** The agent loop's configuration page: how many tool calls one step may run at once. */ +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' -import { PluginCard } from './PluginCard.tsx' +import { PluginConfigForm } from './PluginConfigForm.tsx' import type { AgentLoopCardFace } from './agent-loop-card-controller.ts' -import type {} from './slot-contract.ts' -/** Props the renderer binds for the agent-loop card. */ +/** Props the renderer binds for the agent-loop page. */ export type AgentLoopCardProps = - PropsRuntime<'settings.plugin.item'> + PropsRuntime<'plugins.item'> & PropsLocale<'settings.plugins'> & InjectFace /** - * Render the agent-loop card. - * @param props - locale copy, the card snapshot, and its form actions. - * @returns the card. + * Render the agent loop's one-liner or its configuration form, as the Plugins page asks. + * @param props - the view asked for, locale copy, the form snapshot, and its actions. + * @returns the one-liner, or the form. */ export function AgentLoopCard(props: AgentLoopCardProps) { const { t } = props const state = props.useAgentLoopCard(snapshot => snapshot) + if (props.view === 'summary') return t('agentLoopDescription') return ( - { props.edit('maxParallelToolCalls', text) }} onReset={() => { props.resetField('maxParallelToolCalls') }} /> - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/BashCard.tsx b/packages/client/ui-settings-plugins/src/client/BashCard.tsx index ff130769a0..96f5791be8 100644 --- a/packages/client/ui-settings-plugins/src/client/BashCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/BashCard.tsx @@ -1,31 +1,30 @@ -/** The shell plugin's card: the limits every command the agent runs is bound by. */ +/** The shell plugin's configuration page: the limits every command the agent runs is bound by. */ +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' -import { PluginCard } from './PluginCard.tsx' +import { PluginConfigForm } from './PluginConfigForm.tsx' import type { BashCardFace } from './bash-card-controller.ts' -import type {} from './slot-contract.ts' -/** Props the renderer binds for the shell card. */ +/** Props the renderer binds for the shell page. */ export type BashCardProps = - PropsRuntime<'settings.plugin.item'> + PropsRuntime<'plugins.item'> & PropsLocale<'settings.plugins'> & InjectFace /** - * Render the shell card. - * @param props - locale copy, the card snapshot, and its form actions. - * @returns the card. + * Render the shell plugin's one-liner or its configuration form, as the Plugins page asks. + * @param props - the view asked for, locale copy, the form snapshot, and its actions. + * @returns the one-liner, or the form. */ export function BashCard(props: BashCardProps) { const { t } = props const state = props.useBashCard(snapshot => snapshot) + if (props.view === 'summary') return t('bashDescription') const disabled = !state.writable return ( - { props.edit('maxOutputBytes', text) }} onReset={() => { props.resetField('maxOutputBytes') }} /> - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx b/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx deleted file mode 100644 index c9b4365a3a..0000000000 --- a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Configurable Host plugins contributed to the shared Plugins section. - * - * The tab enumerates settings namespaces but never interprets one — a card - * arrives through `settings.plugin.item` keyed by the namespace it edits, so a - * plugin that ships a browser half owns its own card and this tab only decides - * which keys to dispatch. - */ - -import { Fragment } from 'react' -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from './slot-contract.ts' -import type { ConfigurablePluginsTabFace } from './tab-store.ts' -import css from './PluginsSettingsSection.module.css' - -/** Props the renderer binds for the configurable tab. */ -export type ConfigurablePluginsTabProps = - PropsRuntime<'settings.plugins.tab'> - & PropsLocale<'settings.plugins'> - & PropsRenderSlots<'settings.plugin.item'> - & InjectFace - -/** - * Render cards registered by plugins that expose editable settings. - * @param props - locale copy, slot rendering, and the namespaces to dispatch. - * @returns the card list, or the empty line once the Host has answered. - */ -export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) { - const { t, renderSlot } = props - const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot) - if (namespaces.length > 0) { - return ( -
      - {namespaces.map(ns => ( - // One dispatch per namespace, so the list identity is the namespace - // rather than a position that shifts as cards arrive. - {renderSlot('settings.plugin.item', {}, { entryKey: ns })} - ))} -
    - ) - } - return loaded ?

    {t('empty')}

    : null -} diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.module.css b/packages/client/ui-settings-plugins/src/client/PluginCard.module.css deleted file mode 100644 index a146c5792e..0000000000 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.module.css +++ /dev/null @@ -1,150 +0,0 @@ -/* Plugin card: a header that names the plugin, disclosing its controls in place. */ - -.card { - list-style: none; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 16px; - background: var(--dsw-alias-bg-layer-3); - transition: border-color .16s, background .16s; -} - -.card:hover { - border-color: var(--dsw-alias-label-dimmed); -} - -/* An open card reads as the one being worked on, not merely taller. */ -.cardOpen { - background: var(--dsw-alias-bg-layer-2); - border-color: var(--dsw-alias-label-dimmed); -} - -.header { - width: 100%; - appearance: none; - border: 0; - background: none; - font: inherit; - color: inherit; - text-align: left; - cursor: pointer; - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - border-radius: 12px; -} - -.header:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: -2px; -} - -/* Name over description: the description is what tells two plugins apart, so - it gets its own line rather than trailing the name. */ -.headText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.name { - font-size: 15px; - font-weight: 600; - line-height: 1.4; - color: var(--dsw-alias-label-primary); -} - -.description { - font-size: 13px; - line-height: 1.5; - color: var(--dsw-alias-label-tertiary); -} - -.chevron { - flex: none; - color: var(--dsw-alias-label-tertiary); - transition: transform .16s; -} - -.chevronOpen { - transform: rotate(180deg); -} - -.body { - border-top: 0.5px solid var(--dsw-alias-border-l2); - margin: 0 16px; - padding-bottom: 8px; -} - -.readOnly { - margin: 12px 0 0; - font-size: 12px; - line-height: 1.5; - color: var(--dsw-alias-label-tertiary); -} - -/* Carried on the header so a collapsed card still says it holds edits. */ -/* Placement only: the capsule geometry and palette come from `Tag`. */ -.pending { - flex: none; -} - -.footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding: 12px 0 4px; - border-top: 0.5px solid var(--dsw-alias-border-l2); -} - -.failed { - flex: 1; - min-width: 0; - margin: 0; - font-size: 12px; - line-height: 1.5; - color: var(--dsw-alias-label-error); -} - -.discard, -.save { - appearance: none; - border: 1px solid transparent; - border-radius: 8px; - padding: 5px 14px; - font: inherit; - font-size: 13px; - line-height: 1.5; - cursor: pointer; -} - -.discard { - border-color: var(--dsw-alias-border-l2); - background: none; - color: var(--dsw-alias-label-secondary); -} - -.discard:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); - border-color: var(--dsw-alias-label-dimmed); -} - -.save { - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-bg-layer-3); -} - -.discard:disabled, -.save:disabled { - opacity: 0.4; - cursor: default; -} - -.discard:focus-visible, -.save:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 1px; -} diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx deleted file mode 100644 index 710fb90e06..0000000000 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * One plugin's card: a header naming the plugin and what its settings govern, - * disclosing that plugin's controls in place, with the save that writes them. - * - * The header is its own button rather than a shared disclosure row because a - * card stacks its name over its description, while that row lays the two side - * by side — the layout, not the behavior, is what differs. Disclosure is - * card-local state: which card a user has open is a reading gesture, not - * something the Host or the section has any stake in. Staged edits outlive - * collapsing, so the header marks a card holding unsaved edits. - * - * A card renders nothing while its namespace is unavailable: a deployment that - * does not compose the owning plugin should show no trace of it, rather than a - * disabled card the user cannot act on. - */ - -import { useEffect, useRef, useState, type ReactNode } from 'react' -import clsx from 'clsx' -import { IconChevronDownOutline14, Tag } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CardShell } from './card-form.ts' -import type { PluginsSettingsLocaleKey } from './locales.ts' -import css from './PluginCard.module.css' - -/** Card chrome shared by every plugin section. */ -export interface PluginCardProps { - /** Locale reader for this section's copy. */ - t: (key: PluginsSettingsLocaleKey) => string - /** Locale key of the plugin's name. */ - titleKey: PluginsSettingsLocaleKey - /** Locale key of the line describing what this plugin's settings govern. */ - descriptionKey: PluginsSettingsLocaleKey - /** The card's form state: availability, writability, and what a save would do. */ - state: CardShell - /** Write every staged edit. */ - onSave: () => void - /** Drop every staged edit. */ - onDiscard: () => void - /** The plugin's controls. */ - children: ReactNode -} - -/** - * Render one plugin card. - * @param props - the plugin's copy keys, its form state, and its controls. - * @returns the card, or nothing when the namespace is unavailable. - */ -export function PluginCard(props: PluginCardProps) { - const [open, setOpen] = useState(false) - const saveStarted = useRef(false) - const { state } = props - // Collapse only after Host-confirmed settlement; a rejected write keeps its - // diagnostics and retained drafts visible for correction. - useEffect(() => { - if (state.saving) { - saveStarted.current = true - return - } - if (!saveStarted.current) return - saveStarted.current = false - if (!state.dirty && !state.failed) setOpen(false) - }, [state.dirty, state.failed, state.saving]) - if (!state.available) return null - const title = props.t(props.titleKey) - const blocked = !state.dirty || state.invalid || state.saving - return ( -
  • - - {open - ? ( -
    - {!state.writable ?

    {props.t('readOnly')}

    : null} - {props.children} -
    - {state.failed ?

    {props.t('saveFailed')}

    : null} - - -
    -
    - ) - : null} -
  • - ) -} diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css new file mode 100644 index 0000000000..cf1aade88f --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css @@ -0,0 +1,53 @@ +/* Plugin configuration form: the controls the page shows under a plugin's title, and the save that writes them. */ + +.form { + display: flex; + flex-direction: column; +} + +.readOnly, +.unavailable { + margin: 0 0 12px; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-tertiary); +} + +.footer { + display: flex; + align-items: center; + gap: 8px; + padding-top: 16px; +} + +.failed { + flex: 1; + min-width: 0; + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-error); +} + +.save { + appearance: none; + border: 1px solid transparent; + border-radius: 8px; + padding: 5px 14px; + font: inherit; + font-size: 13px; + line-height: 1.5; + cursor: pointer; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.save:disabled { + opacity: 0.4; + cursor: default; +} + +.save:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx new file mode 100644 index 0000000000..0c3a4bf202 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx @@ -0,0 +1,61 @@ +/** + * One plugin's configuration form as its page on the Plugins page shows it: + * the read-only notice when the deployment stores settings read-only, the + * plugin's controls, and the save that writes every staged edit. The page + * draws the plugin's title and one-liner itself. + * + * Only a save writes. Leaving the page drops every staged edit, so the form + * discards on unmount and offers no discard control. A form whose namespace + * the Host stopped serving says so in place of its controls rather than + * showing fields nothing would accept. + */ + +import { useEffect, useRef, type ReactNode } from 'react' +import type { CardShell } from './card-form.ts' +import type { PluginsSettingsLocaleKey } from './locales.ts' +import css from './PluginConfigForm.module.css' + +/** Form chrome shared by every plugin configuration page. */ +export interface PluginConfigFormProps { + /** Locale reader for this package's copy. */ + t: (key: PluginsSettingsLocaleKey) => string + /** The form state: availability, writability, and what a save would do. */ + state: CardShell + /** Write every staged edit. */ + onSave: () => void + /** Drop every staged edit; the form calls it when it leaves the page. */ + onDiscard: () => void + /** The plugin's controls. */ + children: ReactNode +} + +/** + * Render one plugin's configuration form. + * @param props - the form state, its controls, and the save and discard actions. + * @returns the form, or the unavailable line while the namespace is not served. + */ +export function PluginConfigForm(props: PluginConfigFormProps) { + const { state } = props + const discard = useRef(props.onDiscard) + discard.current = props.onDiscard + useEffect(() => () => { discard.current() }, []) + if (!state.available) return

    {props.t('unavailable')}

    + const blocked = !state.dirty || state.invalid || state.saving + return ( +
    + {!state.writable ?

    {props.t('readOnly')}

    : null} + {props.children} +
    + {state.failed ?

    {props.t('saveFailed')}

    : null} + +
    +
    + ) +} diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx index b1a2e9abf9..1c8148c654 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx @@ -120,7 +120,7 @@ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettin declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Plugins section, configurable-tab, and card copy. */ + /** Built-in plugins section and plugin configuration page copy. */ 'settings.plugins': PluginsSettingsLocaleKey } } diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index 132eef9a1c..11653f0cf0 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -1,29 +1,30 @@ -/** User control for model-selectable subagent delegation in new sessions. */ +/** User control for model-selectable subagent delegation in new sessions, as its configuration page. */ +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import { Switch } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SubagentModelCandidate, SubagentModelSelectionCardFace, } from './subagent-model-selection-card-controller.ts' -import type {} from './slot-contract.ts' -import { PluginCard } from './PluginCard.tsx' +import { PluginConfigForm } from './PluginConfigForm.tsx' import css from './SubagentModelSelectionCard.module.css' -/** Props the renderer binds for the subagent model-selection card. */ +/** Props the renderer binds for the subagent model-selection page. */ export type SubagentModelSelectionCardProps = - PropsRuntime<'settings.plugin.item'> + PropsRuntime<'plugins.item'> & PropsLocale<'settings.plugins'> & InjectFace /** - * Render the default-off preference and its exact adapter-route choices. - * @param props - locale copy, the card snapshot, and its toggle action. - * @returns the preference card, or nothing when the namespace is unavailable. + * Render the preference's one-liner, or the default-off preference and its exact adapter-route choices, as the Plugins page asks. + * @param props - the view asked for, locale copy, the form snapshot, and its toggle action. + * @returns the one-liner, or the preference form. */ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { const { t } = props const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + if (props.view === 'summary') return t('subagentModelSelectionDescription') const availableGroups = new Map ) return ( - {t('subagentModelSelectionConflict')}

    : null} -
    + ) } diff --git a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx b/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx index f1991b7629..42fedcf685 100644 --- a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx @@ -1,35 +1,34 @@ /** - * The web-search provider's card: its endpoint, its per-request search budget, - * and the key — which is written through the credentials domain, never into - * the settings section, so the literal never rides a response. + * The web-search provider's configuration page: its endpoint, its per-request + * search budget, and the key — which is written through the credentials + * domain, never into the settings section, so the literal never rides a response. */ +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { SecretField, ValueField } from './fields.tsx' -import { PluginCard } from './PluginCard.tsx' +import { PluginConfigForm } from './PluginConfigForm.tsx' import type { WebSearchCardFace } from './web-search-card-controller.ts' -import type {} from './slot-contract.ts' -/** Props the renderer binds for the web-search card. */ +/** Props the renderer binds for the web-search page. */ export type WebSearchCardProps = - PropsRuntime<'settings.plugin.item'> + PropsRuntime<'plugins.item'> & PropsLocale<'settings.plugins'> & InjectFace /** - * Render the web-search card. - * @param props - locale copy, the card snapshot, and its form actions. - * @returns the card. + * Render the web-search provider's one-liner or its configuration form, as the Plugins page asks. + * @param props - the view asked for, locale copy, the form snapshot, and its actions. + * @returns the one-liner, or the form. */ export function WebSearchCard(props: WebSearchCardProps) { const { t } = props const state = props.useWebSearchCard(snapshot => snapshot) + if (props.view === 'summary') return t('webSearchDescription') const disabled = !state.writable return ( - { props.edit('maxUses', text) }} onReset={() => { props.resetField('maxUses') }} /> - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 46529882bc..3d68c861b7 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -1,12 +1,12 @@ /** - * Plugins settings surface, browser half — one section whose feature-owned - * tabs include configurable Host plugin cards and read-only inventory. - * - * The section declares `settings.plugins.tab`; its own `configurable` tab then - * declares `settings.plugin.item` and renders whatever cards were registered - * into it. The cards this package ships are the host-plane sections the - * deployment already exposes; each binds its namespace through the client - * settings scope, which keeps them unaware of one another and of other tabs. + * Built-in plugins settings surface and the official plugin configuration + * pages, browser half. The Settings section is the shell around the + * feature-owned tabs registered into `settings.plugins.tab` (the read-only + * inventory ships one); the configuration pages this package ships register + * into the Plugins page's `plugins.item` slot, one per host-plane namespace + * the deployment exposes, and appear in the page's Official group. Each page + * binds its namespace through the client settings scope, which keeps the + * pages unaware of one another and of the section. */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -15,6 +15,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // and the ctx.settingsScope Context merge. Cross-plugin collaboration goes // through the service, never a value import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -22,14 +24,12 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { AgentLoopCard } from './AgentLoopCard.tsx' import { BashCard } from './BashCard.tsx' -import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' -import { ConfigurablePluginsTabController } from './tab-store.ts' import { SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, } from './subagent-model-selection-card-controller.ts' @@ -37,10 +37,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-contro import { en, zh } from './locales.ts' export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx' -export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx' -export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts' -export type { PluginCardProps } from './PluginCard.tsx' -export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' +export type { PluginConfigFormProps } from './PluginConfigForm.tsx' export type { FieldProps } from './fields.tsx' export type { CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, @@ -58,7 +55,7 @@ export const inject = [ ] /** - * Mount the plugin configuration section and the cards this package ships. + * Mount the built-in plugins section and the configuration pages this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { @@ -74,7 +71,7 @@ export function apply(ctx: ClientContext): void { ctx, ) - // The credential a card reports is not part of any settings section, so its + // The credential a page reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that // a key written on another surface reached the Host. ctx.effect( @@ -95,15 +92,54 @@ export function apply(ctx: ClientContext): void { ) ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') + // One configuration page per host-plane namespace, registered while the Host + // serves that namespace: a deployment that does not compose the owning plugin + // shows no trace of it. Card registration order is the page order, not the + // Host's description order, which follows plugin activation and can change + // between boots. + const pages: ReadonlyArray () => void]> = [ + [SHELL_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'bash', order: 10, label: () => t('bashTitle'), locale: NS, inject: () => bash.inject(), + }, BashCard))], + [AGENT_LOOP_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('agentLoopTitle'), locale: NS, inject: () => agentLoop.inject(), + }, AgentLoopCard))], + [SUBAGENT_MODEL_SELECTION_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', + id: 'subagent-model-selection', + order: 30, + label: () => t('subagentModelSelectionTitle'), + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard))], + [WEB_SEARCH_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'web-search', order: 40, label: () => t('webSearchTitle'), locale: NS, inject: () => webSearch.inject(), + }, WebSearchCard))], + ] // The shared SettingsScope mirror updates after document commits and reconnects. - const configurable = new ConfigurablePluginsTabController( - ctx.settingsScope.describe(), () => ctx.slots.entries('settings.plugin.item')) - ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory') - // A card registered after the first read joins the list without a wire call. - ctx.effect( - () => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }), - 'ui-settings-plugins: card ledger', - ) + const describeFace = ctx.settingsScope.describe() + ctx.effect(() => { + const registered = new Map void>() + const sync = (): void => { + const served = new Set(describeFace.getSnapshot().view?.namespaces.map(view => view.ns) ?? []) + for (const [namespace, register] of pages) { + const off = registered.get(namespace) + if (served.has(namespace) && off === undefined) registered.set(namespace, register()) + else if (!served.has(namespace) && off !== undefined) { + off() + registered.delete(namespace) + } + } + } + const unsubscribe = describeFace.subscribe(sync) + void describeFace.ensure() + sync() + return () => { + unsubscribe() + for (const off of registered.values()) off() + registered.clear() + } + }, 'ui-settings-plugins: configuration pages') let tabsVersion = -1 let tabsRevision = -1 @@ -140,8 +176,8 @@ export function apply(ctx: ClientContext): void { }, }) - // This package owns the one Plugins navigation entry and the tab chrome; - // feature plugins contribute pages without competing for Settings nav rows. + // This package owns the one Built-in plugins navigation entry and the tab + // chrome; feature plugins contribute pages without competing for Settings nav rows. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', @@ -151,43 +187,4 @@ export function apply(ctx: ClientContext): void { inject: sectionInjected, children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, }, PluginsSettingsSection)) - - // The existing configuration page is one ordinary tab. It keeps ownership - // of the card slot and the shipped card contributions below. - ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ - name: 'settings.plugins.tab', - id: 'configurable', - order: 0, - label: () => t('configurableTab'), - locale: NS, - inject: () => configurable.inject(), - children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } }, - }, ConfigurablePluginsTab)) - - ctx.slots.inject('settings.plugin.item', function* () { - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: SHELL_NS, - locale: NS, - inject: () => bash.inject(), - }, BashCard) - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: AGENT_LOOP_NS, - locale: NS, - inject: () => agentLoop.inject(), - }, AgentLoopCard) - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: SUBAGENT_MODEL_SELECTION_NS, - locale: NS, - inject: () => subagentModelSelection.inject(), - }, SubagentModelSelectionCard) - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: WEB_SEARCH_NS, - locale: NS, - inject: () => webSearch.inject(), - }, WebSearchCard) - }) } diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index b68ab80beb..30c8ad0523 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -1,10 +1,10 @@ -/** Locale bundles for the plugin configuration section and its plugin cards. */ +/** Locale bundles for the built-in plugins settings section and the plugin configuration pages. */ /** Locale keys these surfaces render. */ export type PluginsSettingsLocaleKey = - | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty' - | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' - | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber' + | 'nav' | 'title' | 'intro' | 'tabs' | 'empty' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' @@ -20,21 +20,17 @@ export type PluginsSettingsLocaleKey = /** English copy. */ export const en: Record = { - nav: 'Plugins', - title: 'Plugins', - intro: 'Configure and inspect the plugins installed in this deployment.', + nav: 'Built-in plugins', + title: 'Built-in plugins', + intro: 'Inspect the plugins this deployment ships.', tabs: 'Plugin views', - configurableTab: 'Plugin configuration', - empty: 'This deployment exposes no plugin settings.', + empty: 'This deployment exposes no plugin views.', overridden: 'Overridden', reset: 'Reset to default', readOnly: 'This deployment stores settings read-only.', - expand: 'Show settings', - collapse: 'Hide settings', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', save: 'Save', saving: 'Saving…', - discard: 'Discard', - unsaved: 'Unsaved', saveFailed: 'The deployment did not accept these values; they were left for you to correct.', invalidNumber: 'Enter a number, or leave blank to use the default.', bashTitle: 'Shell', @@ -76,21 +72,17 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - nav: '插件', - title: '插件', - intro: '配置和查看本部署已安装的插件。', + nav: '内置插件', + title: '内置插件', + intro: '查看内置部署的插件列表', tabs: '插件视图', - configurableTab: '插件配置', - empty: '本部署没有开放任何插件设置。', + empty: '本部署没有开放任何插件视图。', overridden: '已覆盖', reset: '恢复默认', readOnly: '本部署的设置为只读。', - expand: '展开设置', - collapse: '收起设置', + unavailable: '该插件当前未加载,暂时无法配置。', save: '保存', saving: '保存中…', - discard: '放弃修改', - unsaved: '未保存', saveFailed: '本部署没有接受这些值,已保留供你修改。', invalidNumber: '请填数字;留空表示使用默认值。', bashTitle: '终端', diff --git a/packages/client/ui-settings-plugins/src/client/slot-contract.ts b/packages/client/ui-settings-plugins/src/client/slot-contract.ts deleted file mode 100644 index ecb84acadc..0000000000 --- a/packages/client/ui-settings-plugins/src/client/slot-contract.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * The `settings.plugin.item` slot type — one plugin's card inside the - * configurable-plugins tab, keyed by the settings namespace the card edits. - * Options: `key` (the namespace). A card draws its own internals; the tab only - * decides which namespaces to dispatch and stacks what comes back. - * - * Keying on the namespace is what lets a plugin distributed outside this - * repository contribute a card: it registers its own settings namespace on the - * Host and its own card under that key in the browser, and the tab pairs the - * two without ever learning what the namespace means. - * - * TYPE HOME RATIONALE: the tab declares this slot at runtime, and a plugin - * registering its own card already depends on this package for the slot's - * declaration. The type therefore lives with its declarer. - */ -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface SlotMap { - /** One plugin's card inside the plugin configuration section (see module JSDoc). */ - 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps } - } -} - -/** Owner share of a plugin card (the section supplies nothing). */ -export interface SettingsPluginItemOwnerProps { - /** Marker field: card owner props are intentionally empty. */ - children?: never -} diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts deleted file mode 100644 index 00f0b29b9d..0000000000 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * The configurable-plugins tab's card list. - * - * The tab dispatches its slot by settings namespace, so what it renders is - * the intersection of two ledgers: the namespaces the Host serves and the - * cards registered into `settings.plugin.item`. A served namespace no card - * claims renders nothing — another surface owns it, or this deployment ships - * no browser half for it — and a card whose namespace the Host does not serve - * is never dispatched, so a plugin this deployment did not compose leaves no - * trace and does not count toward the empty line. - */ - -import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' - -/** What the section renders. */ -export interface ConfigurablePluginsTabState { - /** - * Whether the Host has answered once. The empty line waits for it: an - * unanswered read is not the same statement as "this deployment configures - * no plugin", and saying the second while the first is true would flash a - * wrong answer on every open. - */ - loaded: boolean - /** - * Namespaces to dispatch, in the order their cards registered, narrowed to - * those the Host serves. Card registration order rather than the Host's - * description order: the latter follows plugin activation, which async - * settings injection can reorder between boots, and a settings page whose - * cards move between visits is worse than one whose order a registrant - * chose. - */ - namespaces: string[] -} - -/** The registration-side face the tab's slot entry injects. */ -export interface ConfigurablePluginsTabFace { - hooks: { - /** Section snapshot bound by the renderer as usePluginConfigSection. */ - configurablePlugins: SnapshotStore - } -} - -/** Derives the served namespaces from the shared describe mirror and pairs them with the cards that claim them. */ -export class ConfigurablePluginsTabController { - private readonly store = createSnapshotStore({ loaded: false, namespaces: [] }) - private disposed = false - private readonly unsubscribe: () => void - - /** - * @param describeFace - the shared mirror's describe face; its refreshes - * (document commits, reconnects) are what keep the served set current. - * @param entries - reads the cards currently registered into the section's slot. - */ - constructor( - private readonly describeFace: SettingsDescribeFace, - private readonly entries: () => readonly StoredEntry[], - ) { - this.unsubscribe = describeFace.subscribe(() => { this.publish() }) - void describeFace.ensure() - this.publish() - } - - /** Republish after the slot ledger changed; a card registered late joins here. */ - refresh(): void { - if (this.disposed) return - this.publish() - } - - /** Stop publishing and stop following the mirror. */ - dispose(): void { - this.disposed = true - this.unsubscribe() - } - - /** - * Build the face the tab's slot registration injects. - * @returns the tab's snapshot source. - */ - inject(): ConfigurablePluginsTabFace { - return { hooks: { configurablePlugins: this.store } } - } - - private publish(): void { - if (this.disposed) return - const mirrored = this.describeFace.getSnapshot() - const loaded = mirrored.view !== undefined - const served = new Set(mirrored.view?.namespaces.map(view => view.ns) ?? []) - const namespaces = this.entries().flatMap(entry => - entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : []) - const previous = this.store.getSnapshot() - // Every settings-document commit refreshes the mirror, and most commits - // change nothing this section shows. An observable source must keep its - // snapshot reference until the fact moves, or each unrelated save - // re-renders the whole card list (packages/client/AGENTS.md reactive rule 5). - if (previous.loaded === loaded - && previous.namespaces.length === namespaces.length - && previous.namespaces.every((ns, index) => ns === namespaces[index])) return - this.store.set({ loaded, namespaces }) - } -} diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 3094d17672..1e65c76255 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -8,9 +8,7 @@ import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -import type { - ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, -} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import type { PluginsSettingsSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' import { apply as hostApply } from '../src/index.ts' @@ -57,10 +55,14 @@ async function bench(served?: string[]) { } } +/** The Settings shell's section slot and the Plugins page's item slot, as the two owners declare them. */ function declareRoot(slots: SlotRegistry): () => void { return slots.register({ name: 'root', - children: { 'settings.section': { kind: 'list', scope: 'root' } }, + children: { + 'settings.section': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + }, } as never, () => null) } @@ -75,7 +77,7 @@ describe('ui-settings-plugins apply', () => { ]) }) - it('registers one Plugins section and declares the tab and card slots', async () => { + it('registers one Built-in plugins section and declares its tab slot, contributing no tab of its own', async () => { const { ctx, slots } = await bench() declareRoot(slots) @@ -84,69 +86,77 @@ describe('ui-settings-plugins apply', () => { const section = slots.entries('settings.section')[0]! expect(section.options).toMatchObject({ id: 'plugins', order: 15 }) // The nav label is a locale-following thunk; owners resolve it at read time. - expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(resolveSlotLabel(section.options.label)).toBe('内置插件') expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' }) - const tab = slots.entries('settings.plugins.tab')[0]! - expect(tab.options).toMatchObject({ id: 'configurable', order: 0 }) - expect(resolveSlotLabel(tab.options.label)).toBe('插件配置') - expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) + expect(slots.entries('settings.plugins.tab')).toHaveLength(0) }) - - it('injects a live tab projection, the card directory, and one business face per card', async () => { - const { ctx, slots } = await bench() + it('injects a live tab projection and one business face per configuration page', async () => { + const { ctx, slots } = await bench(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)() const initialTabs = sectionFace.hooks.tabs.getSnapshot() - expect(initialTabs).toEqual([ - { id: 'configurable', order: 0, label: '插件配置' }, - ]) + expect(initialTabs).toEqual([]) expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) const listener = vi.fn() const unsubscribe = sectionFace.hooks.tabs.subscribe(listener) slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null) + slots.register({ name: 'settings.plugins.tab', id: 'first', order: -1 } as never, () => null) + // Tabs follow their contribution's order, whatever order they registered in. expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([ - { id: 'configurable', order: 0, label: '插件配置' }, + { id: 'first', order: -1, label: '' }, { id: 'plain', order: 0, label: '' }, ]) unsubscribe() - const tab = slots.entries('settings.plugins.tab')[0]! - const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)() - expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins']) - for (const entry of slots.entries('settings.plugin.item')) { + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) + for (const entry of slots.entries('plugins.item')) { const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } - // Each card injects exactly one snapshot store plus its own actions. + // Each page injects exactly one snapshot store plus its own actions. expect(Object.keys(face.hooks)).toHaveLength(1) } }) - it('keys each card it ships on the settings namespace that card edits', async () => { - const { ctx, slots } = await bench() + it('registers one configuration page per served namespace, in its own order, titled in the active locale', async () => { + const { ctx, slots } = await bench(['web-search-deepseek', 'shell', 'agent-loop', 'subagent-model-selection']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) + const entries = slots.entries('plugins.item') + expect(entries.map(entry => entry.options.id)).toEqual(['bash', 'agent-loop', 'subagent-model-selection', 'web-search']) + expect(entries.map(entry => resolveSlotLabel(entry.options.label))).toEqual(['终端', 'Agent 循环', 'Subagent', '网页搜索']) + expect(entries.every(entry => entry.locale === 'settings.plugins')).toBe(true) }) - it('dispatches the served namespaces its cards claim, and no others', async () => { + it('registers the pages of the served namespaces only, and withdraws one the Host stops serving', async () => { // ui-theme is served but belongs to another surface, and a deployment // composing no PowerShell/POSIX executor serves no `bash` at all. - const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek']) + const { ctx, slots, describeSettings, remote } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - const tab = slots.entries('settings.plugins.tab')[0]! - const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)() await vi.waitFor(() => { - expect(face.hooks.configurablePlugins.getSnapshot().namespaces) - .toEqual(['agent-loop', 'web-search-deepseek']) + expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop', 'web-search']) + }) + + describeSettings.mockResolvedValue({ + ok: true, + value: { + writable: true, + hasDocument: true, + namespaces: [{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 }], + }, + }) + remote.emit('settings/document-updated', ['web-search-deepseek', 1]) + + await vi.waitFor(() => { + expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop']) }) }) @@ -231,16 +241,16 @@ describe('ui-settings-plugins apply', () => { }) it('collapses every contribution on teardown', async () => { - const { ctx, slots } = await bench() + const { ctx, slots } = await bench(['shell', 'agent-loop']) declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('settings.plugin.item')).toHaveLength(4) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(2) }) await fiber.dispose() expect(slots.entries('settings.section')).toHaveLength(0) expect(slots.spec('settings.plugins.tab')).toBeUndefined() - expect(slots.spec('settings.plugin.item')).toBeUndefined() + expect(slots.entries('plugins.item')).toHaveLength(0) }) }) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index e84a00cf49..2abfa2eb43 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -8,8 +8,6 @@ import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' import { BashCard } from '../src/client/BashCard.tsx' import type { BashCardProps } from '../src/client/BashCard.tsx' -import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx' -import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' @@ -19,7 +17,6 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' -import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' @@ -56,20 +53,10 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) { render() } -function renderConfigurable(namespaces: string[], cards: Record = {}, loaded = true) { - const store = createSnapshotStore({ loaded, namespaces }) - const props = { - t, - useConfigurablePlugins: bindSnapshotSelector(store), - renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => { - const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey] - return card === undefined ? null :
  • {card}
  • - }, - } as unknown as ConfigurablePluginsTabProps - render() -} +/** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */ +type ConfigView = 'summary' | 'page' -function renderBashCard(state: Partial = {}) { +function renderBashCard(state: Partial = {}, view: ConfigView = 'page') { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), @@ -77,7 +64,7 @@ function renderBashCard(state: Partial = {}) { ...state, }) const actions = cardActions() - const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps + const props = { ...actions, view, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() return { actions, store } } @@ -86,7 +73,7 @@ function renderBash(state: Partial = {}) { return renderBashCard(state).actions } -function renderSubagentModelSelection(state: Partial = {}) { +function renderSubagentModelSelection(state: Partial = {}, view: ConfigView = 'page') { const store = createSnapshotStore({ ...settled, enabled: false, @@ -105,6 +92,7 @@ function renderSubagentModelSelection(state: Partial { it('defaults to the first ordered tab and mounts another only after selection', () => { renderSection([ - { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'configurable', order: 0, label: 'Configurable' }, { id: 'all', order: 10, label: 'Plugin list' }, ]) - const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const configurable = screen.getByRole('tab', { name: 'Configurable' }) const all = screen.getByRole('tab', { name: 'Plugin list' }) expect(configurable.getAttribute('aria-selected')).toBe('true') expect(screen.getByText('configurable')).toBeTruthy() @@ -143,7 +131,7 @@ describe('PluginsSettingsSection', () => { }) it('leads with its own heading and intro', () => { - renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }]) + renderSection([{ id: 'configurable', order: 0, label: 'Configurable' }]) expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() expect(screen.getByText(en.intro)).toBeTruthy() @@ -151,12 +139,12 @@ describe('PluginsSettingsSection', () => { it('moves focus and selection with standard horizontal tab keys', () => { renderSection([ - { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'configurable', order: 0, label: 'Configurable' }, { id: 'all', order: 10, label: 'Plugin list' }, { id: 'diagnostics', order: 20, label: 'Diagnostics' }, ]) - const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const configurable = screen.getByRole('tab', { name: 'Configurable' }) const all = screen.getByRole('tab', { name: 'Plugin list' }) const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' }) expect(configurable.getAttribute('tabindex')).toBe('0') @@ -182,53 +170,31 @@ describe('PluginsSettingsSection', () => { }) }) -describe('ConfigurablePluginsTab', () => { - it('says so when no plugin contributed a card', () => { - renderConfigurable([], { bash: 'shell' }) - - expect(screen.getByText(en.empty)).toBeTruthy() - expect(screen.queryByText('shell')).toBeNull() - }) - - it('withholds the empty line until the Host has answered once', () => { - // An unanswered read is not the statement that this deployment configures - // no plugin; saying it anyway would flash a wrong answer on every open. - renderConfigurable([], { bash: 'shell' }, false) - - expect(screen.queryByText(en.empty)).toBeNull() - }) - - it('dispatches one card per namespace, keyed by it', () => { - renderConfigurable(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' }) - - expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop']) - expect(screen.queryByText(en.empty)).toBeNull() - }) -}) - describe('BashCard', () => { - it('renders nothing while its namespace is unavailable', () => { - const { container } = render(
    ) + it('renders its one-liner alone in the summary view', () => { + renderBashCard({}, 'summary') + + expect(document.body.textContent).toBe(en.bashDescription) + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) + + it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => { renderBash({ available: false }) - expect(container.textContent).toBe('') - expect(screen.queryByText(en.bashTitle)).toBeNull() + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() }) - it('shows the plugin and reveals its fields only once expanded', () => { + it('shows its fields at once on its page, without a title of its own', () => { renderBash() - expect(screen.getByText(en.bashTitle)).toBeTruthy() - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() + expect(screen.queryByText(en.bashTitle)).toBeNull() }) it('stages an edit instead of writing it', () => { const actions = renderBash() - fireEvent.click(screen.getByText(en.bashTitle)) fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } }) @@ -238,7 +204,6 @@ describe('BashCard', () => { it('offers the reset for an overridden field only', () => { const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) }) - fireEvent.click(screen.getByText(en.bashTitle)) // One badge and one reset: the output cap is still inherited. expect(screen.getAllByText(en.overridden)).toHaveLength(1) @@ -249,7 +214,6 @@ describe('BashCard', () => { it('addresses each of its two fields separately', () => { const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) }) - fireEvent.click(screen.getByText(en.bashTitle)) fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } }) fireEvent.click(screen.getByRole('button', { name: en.reset })) @@ -258,103 +222,62 @@ describe('BashCard', () => { expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') }) - it('keeps save and discard inert until something is staged', () => { + it('keeps the save inert until something is staged, and offers no discard', () => { renderBash() - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true) - expect(screen.queryByText(en.unsaved)).toBeNull() + expect(screen.getAllByRole('button')).toHaveLength(1) }) - it('writes the staged edits when saved, and drops them when discarded', () => { + it('writes the staged edits when saved', () => { const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) - fireEvent.click(screen.getByText(en.bashTitle)) fireEvent.click(screen.getByRole('button', { name: en.save })) - fireEvent.click(screen.getByRole('button', { name: en.discard })) expect(actions.save).toHaveBeenCalledOnce() - expect(actions.discard).toHaveBeenCalledOnce() + expect(actions.discard).not.toHaveBeenCalled() }) - it('marks a card holding unsaved edits, collapsed or not', () => { - renderBash({ dirty: true }) + it('drops the staged edits when it leaves the page', () => { + const actions = renderBash({ dirty: true }) + expect(actions.discard).not.toHaveBeenCalled() - expect(screen.getByText(en.unsaved)).toBeTruthy() + cleanup() + + expect(actions.discard).toHaveBeenCalledOnce() }) it('blocks the save while a draft is invalid, and says why', () => { renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false) expect(screen.getByText(en.invalidNumber)).toBeTruthy() }) it('reports a save in flight and refuses another', () => { renderBash({ dirty: true, saving: true }) - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true) }) - it('reports a save the deployment did not accept', () => { + it('reports a save the deployment did not accept and keeps the fields for correction', () => { renderBash({ dirty: true, failed: true }) - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByText(en.saveFailed)).toBeTruthy() + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() }) it('says the document is read-only and disables its controls', () => { renderBash({ writable: false }) - fireEvent.click(screen.getByText(en.bashTitle)) expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) }) - - it('collapses again on a second click', () => { - renderBash() - fireEvent.click(screen.getByText(en.bashTitle)) - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - - fireEvent.click(screen.getByText(en.bashTitle)) - - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('collapses after a successful save settles', () => { - const { actions, store } = renderBashCard({ dirty: true }) - fireEvent.click(screen.getByText(en.bashTitle)) - fireEvent.click(screen.getByRole('button', { name: en.save })) - expect(actions.save).toHaveBeenCalledOnce() - - act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) - act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) }) - - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('keeps a failed save open', () => { - const { store } = renderBashCard({ dirty: true }) - fireEvent.click(screen.getByText(en.bashTitle)) - fireEvent.click(screen.getByRole('button', { name: en.save })) - - act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) - act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) }) - - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - expect(screen.getByText(en.saveFailed)).toBeTruthy() - }) }) describe('SubagentModelSelectionCard', () => { it('renders the default-off preference in its staged plugin card', () => { const actions = renderSubagentModelSelection() - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) expect(control.getAttribute('aria-checked')).toBe('false') @@ -388,7 +311,6 @@ describe('SubagentModelSelectionCard', () => { ], catalogStatus: 'ready', }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() @@ -400,13 +322,11 @@ describe('SubagentModelSelectionCard', () => { it('renders directory progress, failures, unavailable routes, and validation', () => { renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() cleanup() const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) expect(errorActions.retryCatalog).toHaveBeenCalledOnce() @@ -425,32 +345,33 @@ describe('SubagentModelSelectionCard', () => { selected: true, }], }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() cleanup() renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() }) it('distinguishes a stale draft from a rejected save', () => { renderSubagentModelSelection({ dirty: true, conflicted: true }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() expect(screen.queryByText(en.saveFailed)).toBeNull() }) - it('stays hidden when unavailable and disables writes when read-only', () => { + it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => { + renderSubagentModelSelection({}, 'summary') + expect(document.body.textContent).toBe(en.subagentModelSelectionDescription) + + cleanup() renderSubagentModelSelection({ available: false }) - expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + expect(screen.queryByRole('switch')).toBeNull() cleanup() const actions = renderSubagentModelSelection({ writable: false }) - fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch') as HTMLButtonElement expect(control.disabled).toBe(true) fireEvent.click(control) @@ -459,6 +380,15 @@ describe('SubagentModelSelectionCard', () => { }) describe('AgentLoopCard', () => { + it('renders its one-liner alone in the summary view', () => { + const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10') }) + const props = { ...cardActions(), view: 'summary', t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps + render() + + expect(document.body.textContent).toBe(en.agentLoopDescription) + expect(screen.queryByLabelText(en.agentLoopMaxParallel)).toBeNull() + }) + it('stages and saves the only field it owns', () => { const store = createSnapshotStore({ ...settled, @@ -468,12 +398,12 @@ describe('AgentLoopCard', () => { const actions = cardActions() const props = { ...actions, + view: 'page', t, useAgentLoopCard: bindSnapshotSelector(store), } as unknown as AgentLoopCardProps render() - fireEvent.click(screen.getByText(en.agentLoopTitle)) fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } }) fireEvent.click(screen.getByRole('button', { name: en.save })) @@ -489,12 +419,12 @@ describe('AgentLoopCard', () => { const actions = cardActions() const props = { ...actions, + view: 'page', t, useAgentLoopCard: bindSnapshotSelector(store), } as unknown as AgentLoopCardProps render() - fireEvent.click(screen.getByText(en.agentLoopTitle)) fireEvent.click(screen.getByRole('button', { name: en.reset })) expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') @@ -513,14 +443,24 @@ describe('WebSearchCard', () => { ...state, }) const actions = cardActions() - const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps render() return actions } + it('renders its one-liner alone in the summary view', () => { + const store = createSnapshotStore({ + ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, + }) + const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + render() + + expect(document.body.textContent).toBe(en.webSearchDescription) + expect(screen.queryByLabelText(en.webSearchApiKey)).toBeNull() + }) + it('reports whether a key is configured without ever showing one', () => { renderWebSearch({ apiKeyConfigured: true }) - fireEvent.click(screen.getByText(en.webSearchTitle)) expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy() expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password') @@ -528,7 +468,6 @@ describe('WebSearchCard', () => { it('keeps the key control usable while the settings document is read-only', () => { const actions = renderWebSearch({ writable: false }) - fireEvent.click(screen.getByText(en.webSearchTitle)) const key = screen.getByLabelText(en.webSearchApiKey) expect(key).toHaveProperty('disabled', false) @@ -543,7 +482,6 @@ describe('WebSearchCard', () => { // A key coming from the process environment: the settings document is // writable, the credential is not. renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) - fireEvent.click(screen.getByText(en.webSearchTitle)) expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true) expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false) @@ -554,7 +492,6 @@ describe('WebSearchCard', () => { baseURL: field('https://search.test/v1', { overridden: true }), maxUses: field('3', { overridden: true }), }) - fireEvent.click(screen.getByText(en.webSearchTitle)) fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } }) fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 5f4e13259d..6249d0cf6a 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -9,10 +9,6 @@ import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepsee import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' -import { - SettingsDescribeMirror, type SettingsMirrorSnapshot, -} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' -import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { SubagentModelSelectionCardController, subagentModelCandidates, @@ -1018,143 +1014,3 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() }) }) - -describe('ConfigurablePluginsTabController', () => { - function settingsApi(namespaces: string[]) { - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: namespaces.map(ns => ({ - ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0, - })), - }, - })) - return { mirror: new SettingsDescribeMirror(ctxWith({ settings: { describe } })), describe } - } - - /** Slot ledger stand-in: one stored entry per registered card key. */ - function ledger(...keys: string[]) { - return keys.map(key => ({ component: null, options: { key } })) - } - - it('dispatches the served namespaces a card claims, in card registration order', async () => { - const settings = settingsApi(['bash', 'ui-theme', 'agent-loop']) - const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash')) - - await settings.mirror.ensure() - - // ui-theme is served but claimed by no card here — another surface owns - // it. The order is the cards', not the Host's: plugin activation can - // reorder the description between boots. - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces) - .toEqual(['agent-loop', 'bash']) - }) - - it('never dispatches a card whose namespace this deployment does not serve', async () => { - const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek')) - - await settings.mirror.ensure() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - }) - - it('takes a card registered after the read without asking the Host again', async () => { - const settings = settingsApi(['bash']) - let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) - await settings.mirror.ensure() - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) - - entries = ledger('bash') - controller.refresh() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - expect(settings.describe).toHaveBeenCalledOnce() - }) - - it('keeps the namespaces it knew when a refresh fails', async () => { - const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) - await settings.mirror.ensure() - settings.describe.mockRejectedValueOnce(new Error('offline')) - - await settings.mirror.load() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - }) - - it('stops following the mirror once disposed, and never claims it was answered', async () => { - const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) - - controller.dispose() - await settings.mirror.load() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot()) - .toEqual({ loaded: false, namespaces: [] }) - }) - - it('ignores a slot-ledger change that arrives after disposal', async () => { - const settings = settingsApi(['bash']) - let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) - await settings.mirror.ensure() - - controller.dispose() - entries = ledger('bash') - controller.refresh() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) - }) - - it('ignores a mirror notification already queued when disposal starts', () => { - let notify = (): void => {} - let snapshot: SettingsMirrorSnapshot = { - status: 'ready' as const, - view: { writable: true, hasDocument: true, namespaces: [] }, - error: null, - } - const describeFace = { - getSnapshot: () => snapshot, - subscribe: (listener: () => void) => { - notify = listener - return () => {} - }, - ensure: () => Promise.resolve(), - acceptView: vi.fn(), - } as never - const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash')) - expect(controller.inject().hooks.configurablePlugins.getSnapshot()) - .toEqual({ loaded: true, namespaces: [] }) - - controller.dispose() - snapshot = { - status: 'ready', - view: { - writable: true, - hasDocument: true, - namespaces: [{ - ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1, - }], - }, - error: null, - } - notify() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot()) - .toEqual({ loaded: true, namespaces: [] }) - }) - - it('reports the Host answered even when it serves nothing this tab shows', async () => { - const settings = settingsApi(['ui-theme']) - const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) - - await settings.mirror.ensure() - - expect(controller.inject().hooks.configurablePlugins.getSnapshot()) - .toEqual({ loaded: true, namespaces: [] }) - }) -}) diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index 84ae2998fd..a43ca3698f 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-renderer" }, + { + "path": "../ui-plugin-manager" + }, { "path": "../ui-settings" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e080b567cd..a488feace6 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1503,6 +1503,128 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', }, + { + key: 'plugins.bundle.config', + kind: 'keyed', + scope: 'root', + summary: 'A bundle\'s own configuration, keyed by the bundle\'s package name and rendered on the bundle\'s page between its description and its rows (`view: \'page\'` only).', + doc: 'A bundle\'s own configuration, keyed by the bundle\'s package name and\nrendered on the bundle\'s page between its description and its rows\n(`view: \'page\'` only).', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:38', + }, + { + key: 'plugins.item', + kind: 'list', + scope: 'root', + summary: 'One official plugin the Plugins page lists in its Official group after the official bundles: `label` is the card\'s title and `order` its place.', + doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the host-plane configuration pages\n`ui-settings-plugins` ships; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [ + 'client-ui-settings-plugins BashCard id \'bash\'', + 'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'', + 'client-ui-settings-plugins SubagentModelSelectionCard id \'subagent-model-selection\'', + 'client-ui-settings-plugins WebSearchCard id \'web-search\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:32', + }, + { + key: 'plugins.row.config', + kind: 'keyed', + scope: 'root', + summary: 'The configuration of one row a bundle declares, keyed by `#` with the row id as the bundle\'s patch declares it: the row on the bundle\'s page gains a configure control that opens the entry\'s page, headed by the row id and the entry\'s summary.', + doc: 'The configuration of one row a bundle declares, keyed by\n`#` with the row id as the bundle\'s patch declares\nit: the row on the bundle\'s page gains a configure control that opens\nthe entry\'s page, headed by the row id and the entry\'s summary.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:45', + }, { key: 'rightbar', kind: 'single', @@ -1898,46 +2020,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-settings/src/client/contract/slots.ts:74', }, - { - key: 'settings.plugin.item', - kind: 'keyed', - scope: 'root', - summary: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).', - doc: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).', - registerOptions: [ - { - name: 'key', - requirement: 'required', - type: 'string', - doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', - }, - ], - ownerProps: [ - '/** Owner share of a plugin card (the section supplies nothing). */\nexport interface SettingsPluginItemOwnerProps {\n /** Marker field: card owner props are intentionally empty. */\n children?: never\n}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionPendingInteraction: UseSessionPendingInteraction', - 'useWorkspaces: SnapshotSelectorHook', - ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', - occupants: [ - 'client-ui-settings-plugins BashCard', - 'client-ui-settings-plugins AgentLoopCard', - 'client-ui-settings-plugins SubagentModelSelectionCard', - 'client-ui-settings-plugins WebSearchCard', - ], - replaceRisk: 'none', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugin.item\', () => ctx.slots.register(\n { name: \'settings.plugin.item\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:19', - }, { key: 'settings.plugins.tab', kind: 'list', @@ -1982,7 +2064,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ 'client-ui-settings-plugin-inventory PluginInventorySettingsTab id \'all\'', - 'client-ui-settings-plugins ConfigurablePluginsTab id \'configurable\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugins.tab\', () => ctx.slots.register(\n { name: \'settings.plugins.tab\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4e3924116..6e2af7dfcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,9 +222,6 @@ importers: '@deepseek-ai/dsh-experimental-agent-team-web-profile': specifier: workspace:^ version: link:../../packages/experimental/agent-team-web-profile - '@deepseek-ai/dsh-experimental-auto-review': - specifier: workspace:^ - version: link:../../packages/experimental/auto-review '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -4113,6 +4110,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From 62c66dad02da9d16d55fd83aae83853950b67b3e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 21:46:29 +0800 Subject: [PATCH 2/4] feat(web): open a row's configuration from its name and label the guide examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A bundle row with a configuration page is opened from its name, which carries an inline chevron like the plugin cards; the separate configure button and its `configure` label are gone. Status and switch stay at the right. - The three install-guide examples carry an "示例:" / "Example: " label before the code. - README and Agent Note record that a bundle's pages live with the row that carries its browser half: `dsh-client-modules` attaches that half to the row named by the bare package name, so a page keyed to a subpath row disappears with the root row. --- ...onfiguration-on-the-plugins-page.i18n.yaml | 4 +-- ...lugin-configuration-on-the-plugins-page.md | 2 +- ...in-configuration-on-the-plugins-page.zh.md | 2 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +-- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.module.css | 36 +++++++++++++------ .../src/client/PluginManagerPage.tsx | 22 ++++++------ .../ui-plugin-manager/src/client/locales.ts | 4 +-- 9 files changed, 47 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index 5c0a5e7d59..d1c3e82cbb 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md -2026-09-16-plugin-configuration-on-the-plugins-page.md: 99694a278c7a443dd194c3d749b065ad5766b38b -2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 29dc678024c0292ed6837c67e8fdcc09763d67be +2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index 99694a278c..fb330bee44 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -22,7 +22,7 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta ## Consequences -- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the bundle is on. +- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. - The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. - Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index 29dc678024..65c1f5a8bf 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -22,7 +22,7 @@ ## 后果 -- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在组合包开启期间存在。 +- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 - 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 - 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 84a331f76a..06db0de82b 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 04139101d2745aad1e82f3cbd7e507157b614177 -README.zh.md: 0f4207895b235c441b9087440bbcfaefe6477a24 +README.md: 5592a5c566ca058535f05096a89e75d2bf4b4e48 +README.zh.md: 2f7344c754ab6445f172c07b375c57dee372283e diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 04139101d2..5592a5c566 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -77,7 +77,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th ### Configuration slots -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0f4207895b..2f7344c754 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -77,7 +77,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 配置 slot -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 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 008ce5cb62..2971a42af9 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -538,12 +538,19 @@ } .guideExample { - font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } +.guideExample code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); +} + +.guideExampleLabel { + color: var(--dsw-alias-label-tertiary); +} + /* What the check refused, under the field. */ .inputError { margin: -4px 0 0; @@ -1042,24 +1049,31 @@ overflow-wrap: anywhere; } -.rowConfigure { +/* A row with a configuration page: its name opens the page, the arrow says so. */ +.rowOpen { display: inline-flex; - flex: none; align-items: center; gap: 2px; - padding: 2px 6px; + padding: 0; border: 0; - border-radius: 6px; background: transparent; - color: var(--dsw-alias-label-secondary); - font-size: 12.5px; - line-height: 18px; - white-space: nowrap; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } -.rowConfigure:hover { - background: var(--dsw-alias-interactive-bg-hover); +.rowOpen:hover .rowId { + text-decoration: underline; + text-underline-offset: 3px; +} + +.rowOpenIcon { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.rowOpen:hover .rowOpenIcon { color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 310fae7bfd..a3041a3000 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -181,21 +181,20 @@ function RowsSection({ rows, t, toggle, configure }: {
    - {row.rowId} + {configure?.has(row) === true + ? ( + + ) + : {row.rowId}} {row.moduleName}
    {rowStateText(row, t)} - {configure?.has(row) === true - ? ( - - ) - : null} {toggle === undefined ? null : { toggle.onSetEnabled(row, enabled) }} />} @@ -633,7 +632,10 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
    {t(titleKey)} - {t(exampleKey)} + + {t('installGuideExampleLabel')} + {t(exampleKey)} + {t(hintKey)}
    + {tags} +
    + {description === undefined ? null : {description}} +
    + {end === undefined ? null :
    {end}
    } +
    + ) +} + +/** The top every page shares: the crumb that leads back, then the icon with the page's actions at its right. */ +function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { + readonly crumbLabel: string + readonly crumbText: string + readonly onBack: () => void + readonly icon?: ReactNode + readonly actions?: ReactNode +}): ReactNode { + return ( + <> + +
    + + {actions} +
    + + ) +} + /** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { readonly pkg: PackageView @@ -253,20 +299,19 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { data-plugin-status={status} {...highlighted ? { 'data-plugin-highlight': '' } : {}} > -
    - -
    -
    - + {beta ? {t('statusBeta')} : null} {status === 'problem' ? {t('statusProblem')} : null} -
    - {description === undefined ? null : {description}} -
    -
    - -
    -
    + + )} + description={description} + end={} + /> ) } @@ -283,15 +328,7 @@ function ItemCard({ item, t, onOpen, renderSlot }: { }): ReactNode { return (
  • -
    - -
    -
    - -
    - {renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} -
    -
    +
  • ) } @@ -305,13 +342,7 @@ function ItemDetail({ item, t, onBack, renderSlot }: { }): ReactNode { return (
    - -
    - -
    +

    {item.label}

    @@ -340,13 +371,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
    - -
    - -
    + } />

    {row.rowId}

    @@ -391,31 +416,31 @@ function PackageDetail({ const status = packageStatus(pkg) return (
    - -
    - -
    - {pkg.installed - ? ( - - ) - : null} - -
    -
    + + {pkg.installed + ? ( + + ) + : null} + +
    + )} + />

    {title}

    From 57a705fa7eb5201e3fc85e2fd15fbd5bc68a7553 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 22:05:20 +0800 Subject: [PATCH 4/4] feat(web): let the install dialog's close control stop a running install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the Host runs an install, the dialog's close control was disabled. It now asks the Host to stop the run, the same request as Cancel install, and closes the dialog once the Host confirms the stop; when the Host answers too late or cannot confirm, the dialog stays with that word. Escape and the mask keep waiting while the Host owns the operation. The control is labelled 取消安装并关闭 / Cancel install and close while it does this. --- .../client/ui-plugin-manager/README.i18n.yaml | 4 +-- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 15 +++++++++-- .../ui-plugin-manager/src/client/locales.ts | 2 ++ .../src/client/manager-store.ts | 15 ++++++++--- .../tests/components.client.spec.tsx | 7 +++-- .../tests/manager-store.client.spec.ts | 27 +++++++++++++++++++ 8 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 06db0de82b..32dbb8ce3b 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 5592a5c566ca058535f05096a89e75d2bf4b4e48 -README.zh.md: 2f7344c754ab6445f172c07b375c57dee372283e +README.md: 2cfeaad06d2b0247057f5824213411019a8a3cef +README.zh.md: e61a79b1b58d66fbec543966d12b7a8c15e402c9 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5592a5c566..2cfeaad06d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,7 +33,7 @@ The Agent Teams, Agent Teams Web UI, and Auto Authorization Review packages have **Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. -During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again. +During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog while the run is in progress asks the Host to stop it the same way, and the dialog closes once the Host confirms; while the Host prepares, stops, or loads, the dialog cannot be closed. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again. ### Switching a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 2f7344c754..e61a79b1b5 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,7 +33,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 -安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。 +安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。安装进行中关闭对话框,同样会请求 Host 停止安装,Host 确认后对话框才关闭;Host 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。 ### 切换一个组合包 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a3166e231a..d1d366f6a7 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -585,13 +585,17 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl * and failed screens over the same subject card. A failed run that left * install scripts undecided shows them for approval in place of plain retry. */ -function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds }: { +function InstallDialog({ + install, t, onClose, onEditSpec, onRun, onCancel, onCancelAndClose, onToggleDetails, onEnableNow, onApproveBuilds, +}: { readonly install: InstallState readonly t: Translate readonly onClose: () => void readonly onEditSpec: (text: string) => void readonly onRun: () => void readonly onCancel: () => void + /** The close control while the Host runs the install: stop the run, then close. */ + readonly onCancelAndClose: () => void readonly onToggleDetails: () => void readonly onEnableNow: () => void readonly onApproveBuilds: () => void @@ -706,7 +710,13 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog {t('installEdit')} )} -
    @@ -972,6 +982,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onEditSpec={props.editInstallSpec} onRun={props.runInstall} onCancel={props.cancelInstall} + onCancelAndClose={props.cancelInstallAndClose} onToggleDetails={props.toggleInstallDetails} onEnableNow={props.enableInstalled} onApproveBuilds={props.approveBuildsAndRetry} diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index ac78234582..63071a5cb7 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -87,6 +87,7 @@ export const zh = { installEdit: '编辑', installEditAria: '返回编辑', installCancel: '取消安装', + installCloseCancels: '取消安装并关闭', installStarting: '正在准备安装…', installCancelling: '正在停止安装…', installApplying: '正在应用配置,请稍候…', @@ -248,6 +249,7 @@ export const en = { installEdit: 'Edit', installEditAria: 'Back to editing', installCancel: 'Cancel install', + installCloseCancels: 'Cancel install and close', installStarting: 'Preparing installation…', installCancelling: 'Stopping installation…', installApplying: 'Applying configuration, please wait…', 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 6aa6772a28..d091a7703a 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -202,6 +202,11 @@ export interface PluginManagerFace { approveBuildsAndRetry: () => void /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */ cancelInstall: () => void + /** + * While the Host runs the install, ask it to stop and close the dialog once + * it confirms; the dialog stays when it cannot. Otherwise nothing. + */ + cancelInstallAndClose: () => void toggleInstallDetails: () => void /** Enable the bundle the finished install added, then close the dialog and mark it in the list. */ enableInstalled: () => void @@ -374,6 +379,7 @@ export class PluginManagerController { runInstall: () => { void this.runInstall() }, approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() }, cancelInstall: () => { void this.cancelInstall() }, + cancelInstallAndClose: () => { void this.cancelInstall(true) }, toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) }, enableInstalled: () => { void this.enableInstalled() }, clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) }, @@ -595,10 +601,11 @@ export class PluginManagerController { * Leave the check or the failed screen for the spec at once; a Host-owned * run is asked to stop and the dialog waits for the Host's word, since * neither a dropped RPC nor a closed connection means pnpm has stopped. + * @param closeAfter - stop only a running install, and close the dialog once the Host confirms the stop. */ - private async cancelInstall(): Promise { + private async cancelInstall(closeAfter = false): Promise { const install = this.getSnapshot().install - if (install.phase === 'checking' || install.phase === 'failed') { + if (!closeAfter && (install.phase === 'checking' || install.phase === 'failed')) { this.abortInspect() this.offerSpecAgain() return @@ -614,7 +621,9 @@ export class PluginManagerController { return } if (result.value.status === 'cancelled') { - this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq }) + const notice: ManagerNotice = { kind: 'cancelled', seq: ++this.noticeSeq } + if (closeAfter) this.patch({ install: IDLE_INSTALL, notice }) + else this.offerSpecAgain(notice) void this.load() } else if (result.value.status === 'too-late') { this.patchInstall({ phase: 'applying' }) 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 ddab1083df..a3584035a6 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -68,6 +68,7 @@ function renderTab(state: Partial = {}, config: Partial { // Before the first chunk there is no location to name. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true } }) expect(screen.getByText(en.terminalNoOutput)).toBeTruthy() - // Cancel and the back control each ask the Host to stop the run; close waits for the Host's word. + // Cancel and the back control each ask the Host to stop the run; the close control asks too, and closes once the Host confirms. fireEvent.click(screen.getByRole('button', { name: en.installCancel })) fireEvent.click(screen.getByRole('button', { name: en.installEditAria })) expect(actions.cancelInstall).toHaveBeenCalledTimes(2) - expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true) + expect(screen.queryByRole('button', { name: en.close })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels })) + expect(actions.cancelInstallAndClose).toHaveBeenCalledOnce() expect(actions.closeInstall).not.toHaveBeenCalled() }) 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 7efafdb050..3c2af0c17e 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 @@ -467,6 +467,33 @@ describe('PluginManagerController', () => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 }) }) + it.each(['cancelled', 'too-late'] as const)('closes the dialog once the Host confirms the stop its close control asked for, and stays on %s', async (status) => { + const pending = deferred>>() + const { plugins, face, state, controller, started } = bench({ + installBundle: vi.fn().mockReturnValue(pending.promise), + cancelInstall: vi.fn().mockResolvedValue(ok({ status })), + }) + // Before a run exists there is nothing to stop, and the dialog stays as it is. + face.openInstall() + face.cancelInstallAndClose() + expect(state().install).toMatchObject({ open: true, phase: 'idle' }) + face.editInstallSpec('slow') + face.runInstall() + const requestId = await started() + controller.installProgress({ requestId, phase: 'installing' }) + face.cancelInstallAndClose() + expect(state().install.phase).toBe('cancelling') + if (status === 'cancelled') { + await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: '' }) }) + expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 }) + } else { + await vi.waitFor(() => { expect(state().install.phase).toBe('applying') }) + expect(state().install.open).toBe(true) + } + expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId) + pending.resolve(ok({ ...failed(), application: 'cancelled' })) + }) + it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => { const answer = deferred>>() const cancellation = deferred>>()