Forráskód Böngészése

Merge pull request #4376 from deepseek-harness/feat/plugin-page-config-items

feat(web): host plugin configuration on the Plugins page
Yichen Jiang 1 hete
szülő
commit
ce113eb86a
76 módosított fájl, 1706 hozzáadás és 1339 törlés
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
  2. 39 0
      .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
  3. 39 0
      .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
  4. 2 2
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
  5. 2 2
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
  6. 2 2
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
  7. 0 1
      apps/cli/package.json
  8. 1 2
      apps/web/tests/client-plugin-live.e2e.ts
  9. 2 2
      apps/web/tests/expected/agent-preset-authoring/created.expected.md
  10. 2 2
      apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
  11. 2 2
      apps/web/tests/expected/agent-preset-authoring/section.expected.md
  12. 2 2
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  13. 2 2
      apps/web/tests/expected/models-settings-recovery/stored-error.expected.md
  14. 2 2
      apps/web/tests/expected/models-settings/catalog-inputs.expected.md
  15. 2 2
      apps/web/tests/expected/models-settings/configured.expected.md
  16. 2 2
      apps/web/tests/expected/models-settings/declared-edit.expected.md
  17. 2 2
      apps/web/tests/expected/models-settings/declared.expected.md
  18. 2 2
      apps/web/tests/expected/models-settings/empty.expected.md
  19. 2 2
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  20. 2 2
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  21. 2 2
      apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md
  22. 35 0
      apps/web/tests/expected/plugin-config/official.expected.md
  23. 11 0
      apps/web/tests/expected/plugin-config/row.expected.md
  24. 0 45
      apps/web/tests/expected/plugin-config/section.expected.md
  25. 1 1
      apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
  26. 19 11
      apps/web/tests/expected/plugin-manager/live-enabled.expected.md
  27. 19 11
      apps/web/tests/expected/plugin-manager/manager.expected.md
  28. 2 2
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  29. 2 2
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  30. 19 1
      apps/web/tests/fixtures/plugins/fixture-live-client/client.js
  31. 125 88
      apps/web/tests/plugin-config.e2e.ts
  32. 14 8
      apps/web/tests/plugin-manager.e2e.ts
  33. 12 12
      apps/web/tests/settings-chrome.e2e.ts
  34. 2 2
      docs/subsystems/slots.i18n.yaml
  35. 3 1
      docs/subsystems/slots.md
  36. 3 1
      docs/subsystems/slots.zh.md
  37. 0 1
      packages/boot/app-boot/src/profile.ts
  38. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  39. 24 5
      packages/client/ui-plugin-manager/README.md
  40. 24 5
      packages/client/ui-plugin-manager/README.zh.md
  41. 36 7
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
  42. 293 90
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  43. 77 0
      packages/client/ui-plugin-manager/src/client/config-ledger.ts
  44. 16 4
      packages/client/ui-plugin-manager/src/client/index.ts
  45. 22 14
      packages/client/ui-plugin-manager/src/client/locales.ts
  46. 20 6
      packages/client/ui-plugin-manager/src/client/manager-store.ts
  47. 12 9
      packages/client/ui-plugin-manager/src/client/presentation.ts
  48. 47 0
      packages/client/ui-plugin-manager/src/client/slot-contract.ts
  49. 5 0
      packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
  50. 86 11
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  51. 103 0
      packages/client/ui-plugin-manager/tests/config-ledger.client.spec.ts
  52. 36 1
      packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
  53. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  54. 20 20
      packages/client/ui-settings-plugins/README.md
  55. 19 19
      packages/client/ui-settings-plugins/README.zh.md
  56. 4 3
      packages/client/ui-settings-plugins/package.json
  57. 11 12
      packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx
  58. 11 12
      packages/client/ui-settings-plugins/src/client/BashCard.tsx
  59. 0 43
      packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx
  60. 0 150
      packages/client/ui-settings-plugins/src/client/PluginCard.module.css
  61. 0 110
      packages/client/ui-settings-plugins/src/client/PluginCard.tsx
  62. 53 0
      packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css
  63. 61 0
      packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx
  64. 1 1
      packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx
  65. 11 12
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx
  66. 13 14
      packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx
  67. 62 65
      packages/client/ui-settings-plugins/src/client/index.ts
  68. 14 22
      packages/client/ui-settings-plugins/src/client/locales.ts
  69. 0 27
      packages/client/ui-settings-plugins/src/client/slot-contract.ts
  70. 0 102
      packages/client/ui-settings-plugins/src/client/tab-store.ts
  71. 45 35
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  72. 64 127
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  73. 0 144
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  74. 3 0
      packages/client/ui-settings-plugins/tsconfig.json
  75. 122 41
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  76. 3 3
      pnpm-lock.yaml

+ 6 - 0
.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: fb330bee44373d20c7aeea8cee521bf8def025d4
+2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc

+ 39 - 0
.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 `<package name>#<row id>`) 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 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.
+
+## 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.

+ 39 - 0
.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 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
+- 四个页面、它们的表单与 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 记录新的官方分组。

+ 2 - 2
.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

+ 2 - 2
.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
 

+ 2 - 2
.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 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
 
 ## 考虑过的替代方案
 

+ 0 - 1
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:^",

+ 1 - 2
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
 }

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 2 - 2
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 预设

+ 35 - 0
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"

+ 11 - 0
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 "保存"

+ 0 - 45
apps/web/tests/expected/plugin-config/section.expected.md

@@ -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

+ 1 - 1
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

+ 19 - 11
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:

+ 19 - 11
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:

+ 2 - 2
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

+ 2 - 2
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 预设

+ 19 - 1
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)

+ 125 - 88
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<typeof watchConsole>
 
   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<Locator> {
     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<void> {
+    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()
-
-    // 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)
-
-    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
+    const panel = await openPlugins()
+
+    // 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, '[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'])
   })
 })

+ 14 - 8
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)
 

+ 12 - 12
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)')

+ 2 - 2
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

+ 3 - 1
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

+ 3 - 1
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

+ 0 - 1
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:

+ 2 - 2
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: 2cfeaad06d2b0247057f5824213411019a8a3cef
+README.zh.md: e61a79b1b58d66fbec543966d12b7a8c15e402c9

+ 24 - 5
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,15 +25,15 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
 <a id="use-this-package"></a>
 ## 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
 
 **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
 
@@ -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 `<package name>#<row id>`) 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') : <SidebarForm t={t} />))
+```
+
+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.
+
 -----
 
 <a id="understand-the-implementation"></a>
@@ -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. 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.
+
 </details>
 
 -----
@@ -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.
 
 -----
 

+ 24 - 5
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,15 +25,15 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在侧栏选择**插件**。页面首次打开时通过 `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 包名;其他包显示简写包名和原始描述。
 
 ### 安装一个组合包
 
 **添加插件**接受包名(可带版本)、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 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
 
 ### 切换一个组合包
 
@@ -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') : <SidebarForm t={t} />))
+```
+
+组合包的 patch 必须以该 id 声明这一行;注册只在组合包开启期间存在,因此关闭的组合包不显示配置控件。
+
 -----
 
 <a id="understand-the-implementation"></a>
@@ -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 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。
+
 </details>
 
 -----
@@ -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 的官方配置页。
 
 -----
 

+ 36 - 7
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;
@@ -910,12 +917,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 +1049,34 @@
   overflow-wrap: anywhere;
 }
 
+/* A row with a configuration page: its name opens the page, the arrow says so. */
+.rowOpen {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.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);
+}
+
 .rowState {
   display: inline-flex;
   flex: none;

+ 293 - 90
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<PluginManagerFace>
 
+/** 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<PackageRow['phase']>
 
 /** 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()
@@ -157,7 +181,14 @@ function RowsSection({ rows, t, toggle }: {
                 <div className={css.rowLine}>
                   <span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
                   <div className={css.rowMain}>
-                    <span className={css.rowId}>{row.rowId}</span>
+                    {configure?.has(row) === true
+                      ? (
+                        <button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: row.rowId })} onClick={() => { configure.open(row) }}>
+                          <span className={css.rowId}>{row.rowId}</span>
+                          <IconChevronRightOutline14 className={css.rowOpenIcon} aria-hidden="true" />
+                        </button>
+                      )
+                      : <span className={css.rowId}>{row.rowId}</span>}
                     <span className={css.rowModule}>{row.moduleName}</span>
                   </div>
                   <span className={css.rowState}>
@@ -204,6 +235,52 @@ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
   return pkg.enabled ? 'running' : 'disabled'
 }
 
+/** The head every card shares: the pinwheel icon, the name that opens the page beside its tags, its one-liner, and what sits at the end. */
+function CardHead({ title, t, onOpen, tags, description, end }: {
+  readonly title: string
+  readonly t: Translate
+  readonly onOpen: () => void
+  readonly tags?: ReactNode
+  readonly description: ReactNode
+  readonly end?: ReactNode
+}): ReactNode {
+  return (
+    <div className={css.cardHead}>
+      <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
+      <div className={css.cardMain}>
+        <div className={css.titleRow}>
+          <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
+          {tags}
+        </div>
+        {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
+      </div>
+      {end === undefined ? null : <div className={css.cardEnd}>{end}</div>}
+    </div>
+  )
+}
+
+/** 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 (
+    <>
+      <button type="button" className={css.crumb} aria-label={crumbLabel} onClick={onBack}>
+        <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
+        <span>{crumbText}</span>
+      </button>
+      <div className={css.detailHead}>
+        <span className={css.cardIcon} aria-hidden="true">{icon ?? <IconPluginPinwheelOutline16 size={20} />}</span>
+        {actions}
+      </div>
+    </>
+  )
+}
+
 /** 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
@@ -213,7 +290,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 (
     <li
@@ -222,34 +299,103 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
       data-plugin-status={status}
       {...highlighted ? { 'data-plugin-highlight': '' } : {}}
     >
-      <div className={css.cardHead}>
-        <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
-        <div className={css.cardMain}>
-          <div className={css.titleRow}>
-            <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
-            {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
+      <CardHead
+        title={title}
+        t={t}
+        onOpen={onOpen}
+        tags={(
+          <>
+            {beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
             {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
-          </div>
-          {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
+          </>
+        )}
+        description={description}
+        end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
+      />
+    </li>
+  )
+}
+
+/**
+ * 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 (
+    <li className={`${css.card} ${css.cardLink}`} data-plugin-item={item.id}>
+      <CardHead title={item.label} t={t} onOpen={onOpen} description={renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} />
+    </li>
+  )
+}
+
+/** 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 (
+    <div className={css.detail} data-plugin-item-detail={item.id}>
+      <DetailTop crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} />
+      <div className={css.detailMain}>
+        <div className={css.titleRow}>
+          <h3 className={css.detailTitle}>{item.label}</h3>
         </div>
-        <div className={css.cardEnd}>
-          <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
+        <p className={css.detailDesc}>{renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}</p>
+      </div>
+      <div className={css.detailSections} data-plugin-config>
+        {renderSlot('plugins.item', { view: 'page' }, { only: item.id })}
+      </div>
+    </div>
+  )
+}
+
+/**
+ * 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 (
+    <div className={css.detail} data-plugin-row-detail={key}>
+      <DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<IconCordisPluginOutline14 size={20} />} />
+      <div className={css.detailMain}>
+        <div className={css.titleRow}>
+          <h3 className={css.detailTitle}>{row.rowId}</h3>
         </div>
+        <p className={css.detailName}><code>{row.moduleName}</code></p>
+        <p className={css.detailDesc}>{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
       </div>
-    </li>
+      <div className={css.detailSections} data-plugin-config>
+        {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
+      </div>
+    </div>
   )
 }
 
 /**
  * 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,45 +403,49 @@ 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 (
     <div className={css.detail} data-plugin-detail={pkg.name}>
-      <button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
-        <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
-        <span>{t('crumbRoot')}</span>
-      </button>
-      <div className={css.detailHead}>
-        <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
-        <div className={css.detailActions}>
-          {pkg.installed
-            ? (
-              <Button
-                variant="outline"
-                size="sm"
-                className={css.danger}
-                icon={<IconTrashOutline16 size={13} />}
-                aria-label={t('uninstallLabel', { name: title })}
-                disabled={busy || pkg.readOnlyReason !== undefined}
-                onClick={onUninstall}
-              >
-                {t('uninstall')}
-              </Button>
-            )
-            : null}
-          <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
-        </div>
-      </div>
+      <DetailTop
+        crumbLabel={t('backToList')}
+        crumbText={t('crumbRoot')}
+        onBack={onBack}
+        actions={(
+          <div className={css.detailActions}>
+            {pkg.installed
+              ? (
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className={css.danger}
+                  icon={<IconTrashOutline16 size={13} />}
+                  aria-label={t('uninstallLabel', { name: title })}
+                  disabled={busy || pkg.readOnlyReason !== undefined}
+                  onClick={onUninstall}
+                >
+                  {t('uninstall')}
+                </Button>
+              )
+              : null}
+            <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
+          </div>
+        )}
+      />
       <div className={css.detailMain}>
         <div className={css.titleRow}>
           <h3 className={css.detailTitle}>{title}</h3>
-          {pkg.version === undefined ? null : <span className={css.versionTag} data-plugin-version>{t('versionTag', { version: pkg.version })}</span>}
-          {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
+          {pkg.version === undefined ? null : <Tag className={css.versionTag} tone="neutral">{t('versionTag', { version: pkg.version })}</Tag>}
+          {beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
           {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
         </div>
         <p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
@@ -304,10 +454,18 @@ function PackageDetail({
       {pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
       {pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
       <div className={css.detailSections}>
+        {configured
+          ? (
+            <section className={css.detailSection} data-plugin-config>
+              {renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })}
+            </section>
+          )
+          : null}
         <RowsSection
           rows={pkg.rows}
           t={t}
           toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
+          configure={configure}
         />
       </div>
     </div>
@@ -427,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
@@ -499,7 +661,10 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
                       <span className={css.guideIndex} aria-hidden="true">{index + 1}</span>
                       <div className={css.guideMain}>
                         <span className={css.guideTitle}>{t(titleKey)}</span>
-                        <code className={css.guideExample}>{t(exampleKey)}</code>
+                        <span className={css.guideExample}>
+                          <span className={css.guideExampleLabel}>{t('installGuideExampleLabel')}</span>
+                          <code>{t(exampleKey)}</code>
+                        </span>
                         <span className={css.guideHint}>{t(hintKey)}</span>
                       </div>
                       <Button
@@ -545,7 +710,13 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
                 <span>{t('installEdit')}</span>
               </button>
             )}
-          <button type="button" className={css.wizardClose} aria-label={t('close')} disabled={pending} onClick={onClose}>
+          <button
+            type="button"
+            className={css.wizardClose}
+            aria-label={t(phase === 'running' ? 'installCloseCancels' : 'close')}
+            disabled={pending && phase !== 'running'}
+            onClick={phase === 'running' ? onCancelAndClose : onClose}
+          >
             <IconCloseOutline16 size={14} />
           </button>
         </div>
@@ -655,12 +826,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<string | null>(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<View>({ 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 +850,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 => (
+    <PackageCard
+      key={pkg.name}
+      pkg={pkg}
+      t={t}
+      busy={state.busy.includes(pkg.name)}
+      highlighted={state.highlight === pkg.name}
+      onOpen={() => { 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 => (
+      <ItemCard key={`item:${item.id}`} item={item} t={t} renderSlot={renderSlot} onOpen={() => { 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
     : (
       <section className={css.group} data-plugin-scope="global" data-plugin-group={id}>
         <div className={css.groupHead}>
           <h3 className={css.groupTitle}>{heading}</h3>
-          <span className={css.count} data-plugin-count={packages.length}>{packages.length}</span>
+          <span className={css.count} data-plugin-count={cards.length}>{cards.length}</span>
         </div>
-        <ul className={css.cards}>
-          {packages.map(pkg => (
-            <PackageCard
-              key={pkg.name}
-              pkg={pkg}
-              t={t}
-              busy={state.busy.includes(pkg.name)}
-              highlighted={state.highlight === pkg.name}
-              onOpen={() => { setOpenPackage(pkg.name) }}
-              onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
-            />
-          ))}
-        </ul>
+        <ul className={css.cards}>{cards}</ul>
       </section>
     )
 
   return (
     <section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
-      {openPkg === undefined
+      {showsCards
         ? (
           <header className={css.pageHead}>
             <div>
@@ -749,27 +934,44 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
             onDone={props.dismissNotice}
           />
         )}
-      {loaded && openPkg !== undefined
+      {loaded && openPkg !== undefined && openRow !== undefined
+        ? (
+          <RowDetail
+            pkg={openPkg}
+            row={openRow}
+            t={t}
+            renderSlot={renderSlot}
+            onBack={() => { setView({ kind: 'package', name: openPkg.name }) }}
+          />
+        )
+        : null}
+      {loaded && openPkg !== undefined && openRow === undefined
         ? (
           <PackageDetail
             pkg={openPkg}
             t={t}
             busy={state.busy.includes(openPkg.name)}
             rowBusy={row => 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
+        ? <ItemDetail item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
+        : null}
+      {loaded && showsCards
+        ? officialCards.length === 0 && mine.length === 0
           ? <p className={css.empty}>{t('empty')}</p>
           : (
             <>
-              {renderGroup('builtin', t('builtinTitle'), builtin)}
-              {renderGroup('bundles', t('bundlesTitle'), mine)}
+              {renderGroup('official', t('officialTitle'), officialCards)}
+              {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
             </>
           )
         : null}
@@ -780,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}

+ 77 - 0
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<string>
+  readonly rows: ReadonlySet<string>
+}
+
+/**
+ * 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<ConfigLedger> {
+  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<string> =>
+    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() }
+    },
+  }
+}

+ 16 - 4
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',

+ 22 - 14
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,21 @@ export const zh = {
   openDetail: '查看 {name}',
   backToList: '返回插件列表',
   crumbRoot: '插件列表',
+  backToPackage: '返回 {name}',
+  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',
@@ -66,6 +68,7 @@ export const zh = {
   installGuidePathTitle: '本地插件目录',
   installGuidePathExample: '/Users/name/my-plugin',
   installGuidePathHint: '本机上插件目录的绝对路径,适用于自行开发或已下载的插件。',
+  installGuideExampleLabel: '示例:',
   installGuideFill: '填入示例',
   installGuideFillAria: '填入示例 {example}',
   installGuideSafety: '请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。',
@@ -84,6 +87,7 @@ export const zh = {
   installEdit: '编辑',
   installEditAria: '返回编辑',
   installCancel: '取消安装',
+  installCloseCancels: '取消安装并关闭',
   installStarting: '正在准备安装…',
   installCancelling: '正在停止安装…',
   installApplying: '正在应用配置,请稍候…',
@@ -173,15 +177,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 +208,21 @@ export const en = {
   openDetail: 'View {name}',
   backToList: 'Back to plugins',
   crumbRoot: 'Plugins',
+  backToPackage: 'Back to {name}',
+  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',
@@ -224,6 +230,7 @@ export const en = {
   installGuidePathTitle: 'Local plugin directory',
   installGuidePathExample: '/Users/name/my-plugin',
   installGuidePathHint: 'The absolute path of a plugin directory on this machine, developed here or downloaded.',
+  installGuideExampleLabel: 'Example: ',
   installGuideFill: 'Use example',
   installGuideFillAria: 'Use the example {example}',
   installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.',
@@ -242,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…',

+ 20 - 6
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<PluginManagerState>
+    /** The plugins carrying configuration, bound by the renderer as useConfigLedger. */
+    configLedger: HostObservable<ConfigLedger>
   }
   /** Read the Host once the tab first renders. */
   ensure: () => void
@@ -198,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
@@ -345,11 +354,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<ConfigLedger>): 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: () => {
@@ -369,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 }) },
@@ -590,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<void> {
+  private async cancelInstall(closeAfter = false): Promise<void> {
     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
@@ -609,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' })

+ 12 - 9
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<string, { title: PluginManagerLocaleKey; description: PluginManagerLocaleKey }>([
+/** 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<string, { title: PluginManagerLocaleKey; description: PluginManagerLocaleKey; beta: boolean }>([
   ['@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<PackageView, 'name' | 'description'>, t: Translate): { title: string; description: string | undefined } {
+export function packageText(
+  pkg: Pick<PackageView, 'name' | 'description'>, 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 }
 }
 
 /**

+ 47 - 0
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
+     * `<package name>#<row id>` 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 }
+  }
+}

+ 5 - 0
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')

+ 86 - 11
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<PluginManagerState> = {}) {
+/** Configuration entries a test supplies: what each slot cell renders, by `<slot>:<cell>` and the view asked for. */
+type SlotBodies = Record<string, (view: 'summary' | 'page') => ReactNode>
+
+const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() }
+
+function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<ConfigLedger> = {}, bodies: SlotBodies = {}) {
   const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
+  const ledger = createSnapshotStore<ConfigLedger>({ ...NO_CONFIG, ...config })
   const actions = {
     ensure: vi.fn(),
     refresh: vi.fn(),
@@ -60,6 +68,7 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     editInstallSpec: vi.fn(),
     runInstall: vi.fn(),
     cancelInstall: vi.fn(),
+    cancelInstallAndClose: vi.fn(),
     toggleInstallDetails: vi.fn(),
     approveBuildsAndRetry: vi.fn(),
     enableInstalled: vi.fn(),
@@ -75,6 +84,9 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     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(<PluginManagerPage {...props} />)
   return {
@@ -105,7 +117,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 +132,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 +155,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 +203,67 @@ describe('PluginManagerPage', () => {
     }
   })
 
+  describe('configuration pages', () => {
+    const bodies: SlotBodies = {
+      'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' : <form aria-label="bash form" />,
+      'plugins.bundle.config:dsh-better-sidebar': view => view === 'page' ? <form aria-label="sidebar form" /> : null,
+      'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' : <form aria-label="row form" />,
+    }
+
+    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 +334,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()
@@ -388,11 +461,13 @@ describe('PluginManagerPage', () => {
     // 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()
   })
 

+ 103 - 0
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<string, Registration[]> = { 'plugins.item': [], 'plugins.bundle.config': [], 'plugins.row.config': [] }
+  const versions: Record<string, number> = { 'plugins.item': 0, 'plugins.bundle.config': 0, 'plugins.row.config': 0 }
+  const listeners = new Map<string, Set<() => 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)
+  })
+})

+ 36 - 1
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<T>() {
   return { promise, resolve }
 }
 
+/** A configuration ledger with nothing registered, as the page binds it beside the store. */
+const NO_CONFIG: HostObservable<ConfigLedger> = {
+  getSnapshot: () => ({ items: [], bundles: new Set(), rows: new Set() }),
+  subscribe: () => () => {},
+}
+
 function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
   const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
   const plugins = {
@@ -70,7 +78,7 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
   }
   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<PluginInstallRequestId> => {
@@ -459,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<ReturnType<typeof ok<ChangeResult>>>()
+    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<ReturnType<typeof ok<ChangeResult>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()

+ 2 - 2
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

+ 20 - 20
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
 <a id="use-this-package"></a>
 ## 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
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-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.
 
 </details>
 
@@ -66,14 +66,14 @@ Saving writes staged fields through the client settings scope, which fences each
 <a id="further-exploration"></a>
 ## 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.
 <a id="known-limitations-and-deferred-work"></a>
 
 
-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.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 19 - 19
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"
 <a id="use-this-package"></a>
 ## 使用本包
 
-打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 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 卡会同时暂存其权限开关与精确模型复选框。启用时
 <details>
 <summary>实现细节——点击展开</summary>
 
-本分区是一个扩展点加一条分派规则:功能插件拥有各自的卡片;标签页按 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` 事件到来时重读它所关注的引用。
 
 </details>
 
@@ -66,11 +66,11 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
 <a id="further-exploration"></a>
 ## 进一步探索
 
-以下页面覆盖设置底座、清单标签页与卡片背后的持久化 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 卡会同时暂存其权限开关与精确模型复选框。启用时
 <a id="known-limitations-and-deferred-work"></a>
 
 
-这些限制定义哪些插件会出现、列表有多新鲜;它们是当前包约束。
+这些限制定义哪些插件会有页面、分组有多新鲜;它们是当前包约束。
 
-- **只有宿主平面的插件会出现**:由 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`),尽管页面在两者下编辑的都是同样两个字段。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 4 - 3
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",

+ 11 - 12
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<AgentLoopCardFace>
 
 /**
- * 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 (
-    <PluginCard
+    <PluginConfigForm
       t={t}
-      titleKey="agentLoopTitle"
-      descriptionKey="agentLoopDescription"
       state={state}
       onSave={props.save}
       onDiscard={props.discard}
@@ -42,6 +41,6 @@ export function AgentLoopCard(props: AgentLoopCardProps) {
         onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
         onReset={() => { props.resetField('maxParallelToolCalls') }}
       />
-    </PluginCard>
+    </PluginConfigForm>
   )
 }

+ 11 - 12
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<BashCardFace>
 
 /**
- * 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 (
-    <PluginCard
+    <PluginConfigForm
       t={t}
-      titleKey="bashTitle"
-      descriptionKey="bashDescription"
       state={state}
       onSave={props.save}
       onDiscard={props.discard}
@@ -56,6 +55,6 @@ export function BashCard(props: BashCardProps) {
         onEdit={(text) => { props.edit('maxOutputBytes', text) }}
         onReset={() => { props.resetField('maxOutputBytes') }}
       />
-    </PluginCard>
+    </PluginConfigForm>
   )
 }

+ 0 - 43
packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx

@@ -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<ConfigurablePluginsTabFace>
-
-/**
- * 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 (
-      <ul className={css.cards}>
-        {namespaces.map(ns => (
-          // One dispatch per namespace, so the list identity is the namespace
-          // rather than a position that shifts as cards arrive.
-          <Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
-        ))}
-      </ul>
-    )
-  }
-  return loaded ? <p className={css.empty}>{t('empty')}</p> : null
-}

+ 0 - 150
packages/client/ui-settings-plugins/src/client/PluginCard.module.css

@@ -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;
-}

+ 0 - 110
packages/client/ui-settings-plugins/src/client/PluginCard.tsx

@@ -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 (
-    <li className={clsx(css.card, open && css.cardOpen)}>
-      <button
-        type="button"
-        className={css.header}
-        aria-expanded={open}
-        aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
-        onClick={() => { setOpen(!open) }}
-      >
-        <span className={css.headText}>
-          <span className={css.name}>{title}</span>
-          <span className={css.description}>{props.t(props.descriptionKey)}</span>
-        </span>
-        {state.dirty ? <Tag tone="neutral" className={css.pending}>{props.t('unsaved')}</Tag> : null}
-        <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
-      </button>
-      {open
-        ? (
-          <div className={css.body}>
-            {!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
-            {props.children}
-            <div className={css.footer}>
-              {state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
-              <button
-                type="button"
-                className={css.discard}
-                disabled={!state.dirty || state.saving}
-                onClick={props.onDiscard}
-              >
-                {props.t('discard')}
-              </button>
-              <button
-                type="button"
-                className={css.save}
-                disabled={blocked}
-                onClick={props.onSave}
-              >
-                {props.t(state.saving ? 'saving' : 'save')}
-              </button>
-            </div>
-          </div>
-        )
-        : null}
-    </li>
-  )
-}

+ 53 - 0
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;
+}

+ 61 - 0
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 <p className={css.unavailable} role="status">{props.t('unavailable')}</p>
+  const blocked = !state.dirty || state.invalid || state.saving
+  return (
+    <div className={css.form}>
+      {!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
+      {props.children}
+      <div className={css.footer}>
+        {state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
+        <button
+          type="button"
+          className={css.save}
+          disabled={blocked}
+          onClick={props.onSave}
+        >
+          {props.t(state.saving ? 'saving' : 'save')}
+        </button>
+      </div>
+    </div>
+  )
+}

+ 1 - 1
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
   }
 }

+ 11 - 12
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<SubagentModelSelectionCardFace>
 
 /**
- * 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<string, {
     providerName: string
     candidates: SubagentModelCandidate[]
@@ -62,10 +63,8 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
     </label>
   )
   return (
-    <PluginCard
+    <PluginConfigForm
       t={t}
-      titleKey="subagentModelSelectionTitle"
-      descriptionKey="subagentModelSelectionDescription"
       state={state}
       onSave={props.save}
       onDiscard={props.discard}
@@ -133,6 +132,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
       {state.conflicted
         ? <p className={css.conflict} role="status">{t('subagentModelSelectionConflict')}</p>
         : null}
-    </PluginCard>
+    </PluginConfigForm>
   )
 }

+ 13 - 14
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<WebSearchCardFace>
 
 /**
- * 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 (
-    <PluginCard
+    <PluginConfigForm
       t={t}
-      titleKey="webSearchTitle"
-      descriptionKey="webSearchDescription"
       state={state}
       onSave={props.save}
       onDiscard={props.discard}
@@ -73,6 +72,6 @@ export function WebSearchCard(props: WebSearchCardProps) {
         onEdit={(text) => { props.edit('maxUses', text) }}
         onReset={() => { props.resetField('maxUses') }}
       />
-    </PluginCard>
+    </PluginConfigForm>
   )
 }

+ 62 - 65
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<readonly [namespace: string, register: () => () => 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<string, () => 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)
-  })
 }

+ 14 - 22
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<PluginsSettingsLocaleKey, string> = {
-  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<PluginsSettingsLocaleKey, string> = {
 
 /** Simplified Chinese copy. */
 export const zh: Record<PluginsSettingsLocaleKey, string> = {
-  nav: '插件',
-  title: '插件',
-  intro: '配置和查看本部署已安装的插件。',
+  nav: '内置插件',
+  title: '内置插件',
+  intro: '查看内置部署的插件列表',
   tabs: '插件视图',
-  configurableTab: '插件配置',
-  empty: '本部署没有开放任何插件设置。',
+  empty: '本部署没有开放任何插件视图。',
   overridden: '已覆盖',
   reset: '恢复默认',
   readOnly: '本部署的设置为只读。',
-  expand: '展开设置',
-  collapse: '收起设置',
+  unavailable: '该插件当前未加载,暂时无法配置。',
   save: '保存',
   saving: '保存中…',
-  discard: '放弃修改',
-  unsaved: '未保存',
   saveFailed: '本部署没有接受这些值,已保留供你修改。',
   invalidNumber: '请填数字;留空表示使用默认值。',
   bashTitle: '终端',

+ 0 - 27
packages/client/ui-settings-plugins/src/client/slot-contract.ts

@@ -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
-}

+ 0 - 102
packages/client/ui-settings-plugins/src/client/tab-store.ts

@@ -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<ConfigurablePluginsTabState>
-  }
-}
-
-/** 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<ConfigurablePluginsTabState>({ 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 })
-  }
-}

+ 45 - 35
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<string, unknown> }
-      // 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)
   })
 })

+ 64 - 127
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(<PluginsSettingsSection {...props} />)
 }
 
-function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
-  const store = createSnapshotStore<ConfigurablePluginsTabState>({ 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 : <li>{card}</li>
-    },
-  } as unknown as ConfigurablePluginsTabProps
-  render(<ConfigurablePluginsTab {...props} />)
-}
+/** 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<BashCardState> = {}) {
+function renderBashCard(state: Partial<BashCardState> = {}, view: ConfigView = 'page') {
   const store = createSnapshotStore<BashCardState>({
     ...settled,
     timeoutMs: field('60000'),
@@ -77,7 +64,7 @@ function renderBashCard(state: Partial<BashCardState> = {}) {
     ...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(<BashCard {...props} />)
   return { actions, store }
 }
@@ -86,7 +73,7 @@ function renderBash(state: Partial<BashCardState> = {}) {
   return renderBashCard(state).actions
 }
 
-function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
+function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}, view: ConfigView = 'page') {
   const store = createSnapshotStore<SubagentModelSelectionCardState>({
     ...settled,
     enabled: false,
@@ -105,6 +92,7 @@ function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardS
   }
   const props = {
     ...actions,
+    view,
     t,
     useSubagentModelSelectionCard: bindSnapshotSelector(store),
   } as unknown as SubagentModelSelectionCardProps
@@ -122,11 +110,11 @@ describe('PluginsSettingsSection', () => {
 
   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' })
+describe('BashCard', () => {
+  it('renders its one-liner alone in the summary view', () => {
+    renderBashCard({}, 'summary')
 
-    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
-    expect(screen.queryByText(en.empty)).toBeNull()
+    expect(document.body.textContent).toBe(en.bashDescription)
+    expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
   })
-})
 
-describe('BashCard', () => {
-  it('renders nothing while its namespace is unavailable', () => {
-    const { container } = render(<div />)
+  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()
+
+    cleanup()
 
-    expect(screen.getByText(en.unsaved)).toBeTruthy()
+    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<AgentLoopCardState>({ ...settled, maxParallelToolCalls: field('10') })
+    const props = { ...cardActions(), view: 'summary', t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps
+    render(<AgentLoopCard {...props} />)
+
+    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<AgentLoopCardState>({
       ...settled,
@@ -468,12 +398,12 @@ describe('AgentLoopCard', () => {
     const actions = cardActions()
     const props = {
       ...actions,
+      view: 'page',
       t,
       useAgentLoopCard: bindSnapshotSelector(store),
     } as unknown as AgentLoopCardProps
     render(<AgentLoopCard {...props} />)
 
-    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(<AgentLoopCard {...props} />)
 
-    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(<WebSearchCard {...props} />)
     return actions
   }
 
+  it('renders its one-liner alone in the summary view', () => {
+    const store = createSnapshotStore<WebSearchCardState>({
+      ...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(<WebSearchCard {...props} />)
+
+    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' } })

+ 0 - 144
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: [] })
-  })
-})

+ 3 - 0
packages/client/ui-settings-plugins/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../ui-renderer"
     },
+    {
+      "path": "../ui-plugin-manager"
+    },
     {
       "path": "../ui-settings"
     },

+ 122 - 41
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<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    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: \'<one key the owner dispatches>\' },\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<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    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 `<package name>#<row id>` 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`<package name>#<row id>` 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<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    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: \'<one key the owner dispatches>\' },\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<WorkspaceSnapshot>',
-      'usePanelInfo: UsePanelInfo',
-      'useSessions: UseSessions',
-      'useSessionPendingInteraction: UseSessionPendingInteraction',
-      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
-    ],
-    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: \'<one key the owner dispatches>\' },\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}',

+ 3 - 3
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
@@ -4122,6 +4119,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