فهرست منبع

feat(web): move a preset's capabilities and settings onto its detail page

Agent presets: every card that composes carries a gear that opens the
preset's detail page (breadcrumb, name, marks, description) and declares a
`settings.agentPreset.detail` slot for the sections other plugins contribute.

Plugin manager: the session-plugins group leaves the tab for the detail
page's Capabilities section, with a red Delete on rows the person added and
an Add menu over the installed modules the preset lacks; the manage tab now
precedes the configuration tab.

Plugin configuration: the scope switch is gone. The tab edits the values
shared by all presets, and the same cards form the detail page's Settings
section, registered under a second keyed slot
(`settings.agentPreset.plugin.item`) and editing the preset's own scope while
the page is open. The roster dependency the switch needed leaves with it.

Web e2e goldens, both READMEs of each package, the Agent Note, the slot
catalog, and the inspect catalog follow.
Yichen Jiang 2 هفته پیش
والد
کامیت
d7d7b301d5
59فایلهای تغییر یافته به همراه1460 افزوده شده و 1195 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md
  4. 15 0
      apps/web/tests/expected/agent-preset-authoring/created.expected.md
  5. 18 0
      apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
  6. 12 0
      apps/web/tests/expected/agent-preset-authoring/section.expected.md
  7. 1 6
      apps/web/tests/expected/plugin-config/section.expected.md
  8. 1 88
      apps/web/tests/expected/plugin-manager/manager.expected.md
  9. 115 0
      apps/web/tests/expected/plugin-manager/preset-detail.expected.md
  10. 1 0
      apps/web/tests/plugin-config.e2e.ts
  11. 49 38
      apps/web/tests/plugin-manager.e2e.ts
  12. 2 2
      packages/client/ui-agent-preset/README.i18n.yaml
  13. 2 2
      packages/client/ui-agent-preset/README.md
  14. 2 2
      packages/client/ui-agent-preset/README.zh.md
  15. 76 0
      packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css
  16. 46 2
      packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
  17. 5 0
      packages/client/ui-agent-preset/src/client/index.ts
  18. 5 0
      packages/client/ui-agent-preset/src/client/locales.ts
  19. 25 0
      packages/client/ui-agent-preset/src/client/slot-contract.ts
  20. 5 0
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  21. 39 1
      packages/client/ui-agent-preset/tests/section.client.spec.tsx
  22. 2 2
      packages/client/ui-settings-plugin-manager/README.i18n.yaml
  23. 3 3
      packages/client/ui-settings-plugin-manager/README.md
  24. 3 3
      packages/client/ui-settings-plugin-manager/README.zh.md
  25. 27 0
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css
  26. 38 258
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx
  27. 196 0
      packages/client/ui-settings-plugin-manager/src/client/PresetPluginsSection.tsx
  28. 22 8
      packages/client/ui-settings-plugin-manager/src/client/index.ts
  29. 16 18
      packages/client/ui-settings-plugin-manager/src/client/locales.ts
  30. 5 8
      packages/client/ui-settings-plugin-manager/src/client/manager-store.ts
  31. 145 0
      packages/client/ui-settings-plugin-manager/src/client/presentation.ts
  32. 14 2
      packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx
  33. 1 77
      packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
  34. 0 2
      packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts
  35. 210 0
      packages/client/ui-settings-plugin-manager/tests/preset-section.client.spec.tsx
  36. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  37. 5 4
      packages/client/ui-settings-plugins/README.md
  38. 5 4
      packages/client/ui-settings-plugins/README.zh.md
  39. 0 1
      packages/client/ui-settings-plugins/package.json
  40. 8 65
      packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx
  41. 10 52
      packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css
  42. 65 0
      packages/client/ui-settings-plugins/src/client/PresetSettingsSection.tsx
  43. 1 1
      packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts
  44. 1 1
      packages/client/ui-settings-plugins/src/client/bash-card-controller.ts
  45. 59 73
      packages/client/ui-settings-plugins/src/client/index.ts
  46. 5 21
      packages/client/ui-settings-plugins/src/client/locales.ts
  47. 0 168
      packages/client/ui-settings-plugins/src/client/scope-switcher.ts
  48. 7 6
      packages/client/ui-settings-plugins/src/client/scoped-form.ts
  49. 1 1
      packages/client/ui-settings-plugins/src/client/skill-filesystem-card-controller.ts
  50. 20 11
      packages/client/ui-settings-plugins/src/client/slot-contract.ts
  51. 1 1
      packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts
  52. 1 1
      packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts
  53. 31 59
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  54. 0 116
      packages/client/ui-settings-plugins/tests/scope-switcher.client.spec.ts
  55. 47 61
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  56. 0 3
      packages/client/ui-settings-plugins/tsconfig.json
  57. 0 4
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  58. 86 11
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  59. 0 3
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
-2026-09-04-plugin-management-in-web-settings.md: 214d14c1f0718c044dff615ce3683ff481ee70f0
-2026-09-04-plugin-management-in-web-settings.zh.md: 963d1fe13cd24912db3351e773052425e0a95080
+2026-09-04-plugin-management-in-web-settings.md: 9d6af8098549f9842c5da3bf323a4cc899f16288
+2026-09-04-plugin-management-in-web-settings.zh.md: cd46b9fc59a54344fae619310a0d4cf047ccdc35

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md

@@ -16,7 +16,7 @@ The Host could manage a profile's plugins — install, enable, disable, retry, c
 
 **A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab after the configuration tab. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for a confirmation naming the dependents the Host reports. The install dialog also lists the packages the Host removed again after `pnpm add` — a package that is not a dsh package, a bundle whose row id another layer owns — with the Host's reason, and every action renders the two refusals any mutation can meet, `plugins/busy` and `plugins/agents-running`, in the Host's words.
 
-**The page speaks two nouns and shows neither.** A person who does not know the Loader sees *plugin packs* (switched as a whole, serving every session) and *plugins* (joining a preset or every session through **Add to…**), each card a name, a one-liner, and its switch or its **Add to…** menu; a card is tagged only for a pending restart or a problem, running and off being what the switch shows. Built-in packs sit in the same list with a *Built-in* mark and a locked switch; uninstall, retry, and a pack's components live in the expanded card. Harness modules in a preset read by the tab's own dictionary (`name.<row id or module slug>` / `desc.<…>`), installed ones by their manifest title and description with a *Local* mark. Entry ids, module names, cordis copies, probe times, and overridden rows stay off the page; the read-only **Plugin list** tab is gone with its package, `ui-settings-plugin-inventory`, while the `pluginInventory` Remote stays as the manager's source of preset compositions and host-tree module names.
+**The page speaks two nouns and shows neither.** A person who does not know the Loader sees *plugin packs* (switched as a whole, serving every session) and *plugins* (joining a preset or every session through **Add to…**), each card a name, a one-liner, and its switch or its **Add to…** menu; a card is tagged only for a pending restart or a problem, running and off being what the switch shows. Built-in packs sit in the same list with a *Built-in* mark and a locked switch; uninstall, retry, and a pack's components live in the expanded card. A preset's composition is not on the tab: the Agent presets section declares a `settings.agentPreset.detail` slot, every preset card carries a gear that opens the preset's detail page, and the manager registers its **Capabilities** section there — the preset's rows as the same cards, **Delete** on rows the person added, and an **Add** menu over the installed modules the preset lacks — so the tab lists what is installed and the preset page says what a preset does. The plugin configuration tab loses its scope switch for the same reason: it edits the values shared by all presets, and the same cards form the **Settings** section of the detail page, registered under a second keyed slot (`settings.agentPreset.plugin.item`) and editing the preset's own scope while the page is open; the settings shell mounts one section at a time, so one scope selection serves both surfaces. The manager tab is ordered before the configuration tab. Harness modules in a preset read by the tab's own dictionary (`name.<row id or module slug>` / `desc.<…>`), installed ones by their manifest title and description with a *Local* mark. Entry ids, module names, cordis copies, probe times, and overridden rows stay off the page; the read-only **Plugin list** tab is gone with its package, `ui-settings-plugin-inventory`, while the `pluginInventory` Remote stays as the manager's source of preset compositions and host-tree module names.
 
 **The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 **管理另开一个标签页。** `ui-settings-plugin-manager` 在配置标签页之后注册**插件管理**标签页。它从 `plugins` Remote 读包、从 `pluginInventory` 读预设组合,在每次操作与每个 `plugins/changed` 之后重新读取,并把 `plugins/install-log` 流进安装对话框。破坏性操作等待一次确认,确认框点名宿主报告的依赖方。安装对话框还会列出宿主在 `pnpm add` 之后又移除掉的包——不是 dsh 包的、行 id 已被别的层占有的组合包——并附宿主的原因;每个操作都会把任何变更都可能遇到的两种拒绝 `plugins/busy` 与 `plugins/agents-running` 按宿主的原话显示出来。
 
-**页面只说两个名词,且都不显示出来。** 不认识 Loader 的人看到的是*插件包*(整体启停、对所有会话生效)与*插件*(经**加入到…**加入某个预设或所有会话),每张卡片只有名字、一句话,以及开关或**加入到…**菜单;只有需重启或异常时才打标签,运行中与已停用由开关表达。内置插件包与其他卡片并列,带*内置*标记与锁定的开关;卸载、重试与插件包的组件都在展开区。预设里的 harness 模块按标签页自己的字典命名(`name.<行 id 或模块 slug>` / `desc.<…>`),自己装的模块按 manifest 的标题与描述命名并带*本地*标记。entry id、模块名、cordis 副本、探测时间、覆盖行都不再出现在页面上;只读的**插件列表**标签页连同其包 `ui-settings-plugin-inventory` 一起删除,`pluginInventory` Remote 保留,作为管理器读取预设组合与宿主树模块名的来源。
+**页面只说两个名词,且都不显示出来。** 不认识 Loader 的人看到的是*插件包*(整体启停、对所有会话生效)与*插件*(经**加入到…**加入某个预设或所有会话),每张卡片只有名字、一句话,以及开关或**加入到…**菜单;只有需重启或异常时才打标签,运行中与已停用由开关表达。内置插件包与其他卡片并列,带*内置*标记与锁定的开关;卸载、重试与插件包的组件都在展开区。预设的组合不在这个标签页上:Agent 预设分区声明 `settings.agentPreset.detail` 槽位,每张预设卡片带一个打开详情页的齿轮,管理器把**能力**段注册到那里——预设的行仍是同样的卡片,用户添加的行带**删除**,段头的**添加**菜单列出预设还没有的已安装模块——于是标签页只说装了什么,预设页说一个预设能做什么。插件配置标签页出于同样的理由去掉了作用域切换:它编辑所有预设共用的值,而同样的卡片构成详情页的**设置**段,注册在第二个键控槽位(`settings.agentPreset.plugin.item`)下,在页面打开期间编辑该预设自己的作用域;设置外壳一次只挂载一个分区,因此一个作用域选择服务两个表面。管理标签页排在配置标签页之前。预设里的 harness 模块按标签页自己的字典命名(`name.<行 id 或模块 slug>` / `desc.<…>`),自己装的模块按 manifest 的标题与描述命名并带*本地*标记。entry id、模块名、cordis 副本、探测时间、覆盖行都不再出现在页面上;只读的**插件列表**标签页连同其包 `ui-settings-plugin-inventory` 一起删除,`pluginInventory` Remote 保留,作为管理器读取预设组合与宿主树模块名的来源。
 
 **安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('<name>')`。
 

+ 15 - 0
apps/web/tests/expected/agent-preset-authoring/created.expected.md

@@ -25,6 +25,9 @@
       - 'button "当前使用: 标准模式" [disabled] [pressed]':
         - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
+      - 'button "配置: 标准模式"':
+        - img
+        - text: 配置
       - 'button "查看: 标准模式"':
         - img
         - text: 查看
@@ -35,6 +38,9 @@
       - 'button "设为默认: PTC 模式"':
         - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
         - code: ptc
+      - 'button "配置: PTC 模式"':
+        - img
+        - text: 配置
       - 'button "查看: PTC 模式"':
         - img
         - text: 查看
@@ -45,6 +51,9 @@
       - 'button "设为默认: 极简模式"':
         - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
         - code: minimal
+      - 'button "配置: 极简模式"':
+        - img
+        - text: 配置
       - 'button "查看: 极简模式"':
         - img
         - text: 查看
@@ -55,6 +64,9 @@
       - 'button "设为默认: 创造模式"':
         - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
         - code: cordis
+      - 'button "配置: 创造模式"':
+        - img
+        - text: 配置
       - 'button "查看: 创造模式"':
         - img
         - text: 查看
@@ -67,6 +79,9 @@
       - 'button "设为默认: 我的模式"':
         - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
         - code: my-agent
+      - 'button "配置: 我的模式"':
+        - img
+        - text: 配置
       - 'button "查看路径: 我的模式"':
         - img
         - text: 查看路径

+ 18 - 0
apps/web/tests/expected/agent-preset-authoring/damaged.expected.md

@@ -25,6 +25,9 @@
       - 'button "当前使用: 标准模式" [disabled] [pressed]':
         - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
+      - 'button "配置: 标准模式"':
+        - img
+        - text: 配置
       - 'button "查看: 标准模式"':
         - img
         - text: 查看
@@ -35,6 +38,9 @@
       - 'button "设为默认: PTC 模式"':
         - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
         - code: ptc
+      - 'button "配置: PTC 模式"':
+        - img
+        - text: 配置
       - 'button "查看: PTC 模式"':
         - img
         - text: 查看
@@ -45,6 +51,9 @@
       - 'button "设为默认: 极简模式"':
         - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
         - code: minimal
+      - 'button "配置: 极简模式"':
+        - img
+        - text: 配置
       - 'button "查看: 极简模式"':
         - img
         - text: 查看
@@ -55,6 +64,9 @@
       - 'button "设为默认: 创造模式"':
         - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
         - code: cordis
+      - 'button "配置: 创造模式"':
+        - img
+        - text: 配置
       - 'button "查看: 创造模式"':
         - img
         - text: 查看
@@ -68,6 +80,9 @@
         - text: broken-yaml 加载失败 自定义 暂无描述。
         - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
         - code: broken-yaml
+      - 'button "配置: broken-yaml" [disabled]':
+        - img
+        - text: 配置
       - 'button "查看路径: broken-yaml"':
         - img
         - text: 查看路径
@@ -82,6 +97,9 @@
         - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
         - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
         - code: ghost
+      - 'button "配置: 幽灵预设" [disabled]':
+        - img
+        - text: 配置
       - 'button "查看路径: 幽灵预设"':
         - img
         - text: 查看路径

+ 12 - 0
apps/web/tests/expected/agent-preset-authoring/section.expected.md

@@ -25,6 +25,9 @@
       - 'button "当前使用: 标准模式" [disabled] [pressed]':
         - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
+      - 'button "配置: 标准模式"':
+        - img
+        - text: 配置
       - 'button "查看: 标准模式"':
         - img
         - text: 查看
@@ -35,6 +38,9 @@
       - 'button "设为默认: PTC 模式"':
         - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
         - code: ptc
+      - 'button "配置: PTC 模式"':
+        - img
+        - text: 配置
       - 'button "查看: PTC 模式"':
         - img
         - text: 查看
@@ -45,6 +51,9 @@
       - 'button "设为默认: 极简模式"':
         - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
         - code: minimal
+      - 'button "配置: 极简模式"':
+        - img
+        - text: 配置
       - 'button "查看: 极简模式"':
         - img
         - text: 查看
@@ -55,6 +64,9 @@
       - 'button "设为默认: 创造模式"':
         - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
         - code: cordis
+      - 'button "配置: 创造模式"':
+        - img
+        - text: 配置
       - 'button "查看: 创造模式"':
         - img
         - text: 查看

+ 1 - 6
apps/web/tests/expected/plugin-config/section.expected.md

@@ -20,14 +20,9 @@
   - heading "插件" [level=2]
   - paragraph: 配置和查看本部署已安装的插件。
   - tablist "插件视图":
-    - tab "插件配置" [selected]
     - tab "插件管理"
+    - tab "插件配置" [selected]
   - tabpanel "插件配置":
-    - text: 生效范围
-    - button "选择这些设置生效的 Agent 预设":
-      - text: 所有预设
-      - img
-    - paragraph: 这里的值对所有 Agent 预设生效,除非某个预设单独覆盖。
     - list:
       - listitem:
         - 'button "展开设置: 终端"':

+ 1 - 88
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -20,8 +20,8 @@
   - heading "插件" [level=2]
   - paragraph: 配置和查看本部署已安装的插件。
   - tablist "插件视图":
-    - tab "插件配置"
     - tab "插件管理" [selected]
+    - tab "插件配置"
   - tabpanel "插件管理":
     - button "刷新"
     - button "添加"
@@ -38,90 +38,3 @@
         - button "加入到…"
         - button "展开 示例插件":
           - img
-    - heading "会话插件" [level=3]
-    - button "选择要管理的 Agent 预设":
-      - text: 标准模式(默认)
-      - img
-    - paragraph: 这个预设的会话会用到的插件
-    - list:
-      - listitem:
-        - text: 人设 助手的身份与语气
-        - switch "启用 人设" [checked]
-      - listitem:
-        - text: 项目指令 读取 AGENTS.md 等项目说明
-        - switch "启用 项目指令" [checked]
-      - listitem:
-        - text: 终端 运行 shell 命令
-        - switch "启用 终端" [checked]
-      - listitem:
-        - text: PowerShell 终端 在 Windows 上运行 PowerShell
-        - switch "启用 PowerShell 终端" [disabled]
-      - listitem:
-        - text: 文件读写 读取、创建和修改文件
-        - switch "启用 文件读写" [checked]
-      - listitem:
-        - text: 文件搜索 按名称和内容查找文件
-        - switch "启用 文件搜索" [checked]
-      - listitem:
-        - text: 后台任务 启动和查看长时间运行的命令
-        - switch "启用 后台任务" [checked]
-      - listitem:
-        - text: 技能 从本地目录加载技能
-        - switch "启用 技能" [checked]
-      - listitem:
-        - text: 技能调用 让助手调用技能
-        - switch "启用 技能调用" [checked]
-      - listitem:
-        - text: 目标命令 /goal 命令
-        - switch "启用 目标命令" [checked]
-      - listitem:
-        - text: 目标 记录并跟踪当前目标
-        - switch "启用 目标" [checked]
-      - listitem:
-        - text: 计划模式 先规划再动手
-        - switch "启用 计划模式" [checked]
-      - listitem:
-        - text: 上下文压缩 对话过长时自动整理
-        - switch "启用 上下文压缩" [checked]
-      - listitem:
-        - text: 压缩命令 /compact 命令
-        - switch "启用 压缩命令" [checked]
-      - listitem:
-        - text: 工具结果裁剪 丢弃过时的工具输出
-        - switch "启用 工具结果裁剪" [checked]
-      - listitem:
-        - text: 子代理控制 查看和管理子代理
-        - switch "启用 子代理控制" [checked]
-      - listitem:
-        - text: 子代理列表 列出正在运行的子代理
-        - switch "启用 子代理列表" [checked]
-      - listitem:
-        - text: 子代理 把任务分派给并行的助手
-        - switch "启用 子代理" [checked]
-      - listitem:
-        - text: 分支子代理 复制当前上下文继续任务
-        - switch "启用 分支子代理" [checked]
-      - listitem:
-        - text: Codex 子代理 把任务交给 Codex 执行
-        - switch "启用 Codex 子代理" [disabled]
-      - listitem:
-        - text: Claude Code 子代理 把任务交给 Claude Code 执行
-        - switch "启用 Claude Code 子代理" [disabled]
-      - listitem:
-        - text: 工作流引擎 在后台线程运行工作流
-        - switch "启用 工作流引擎" [checked]
-      - listitem:
-        - text: 工作流 编排多步骤任务
-        - switch "启用 工作流" [checked]
-      - listitem:
-        - text: Ralph 循环 反复执行直到完成
-        - switch "启用 Ralph 循环" [checked]
-      - listitem:
-        - text: 向用户提问 需要时向你确认
-        - switch "启用 向用户提问" [checked]
-      - listitem:
-        - text: 待办 维护任务清单
-        - switch "启用 待办" [checked]
-      - listitem:
-        - text: 网页 搜索和抓取网页
-        - switch "启用 网页" [checked]

+ 115 - 0
apps/web/tests/expected/plugin-manager/preset-detail.expected.md

@@ -0,0 +1,115 @@
+- button "返回 Agent 预设":
+  - img
+  - text: Agent 预设 标准模式
+- heading "标准模式" [level=2]
+- text: 内置 当前使用
+- paragraph: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
+- heading "能力" [level=3]
+- button "给 标准模式 添加能力": 添加
+- status:
+  - text: 已完成。
+  - button "知道了"
+- list:
+  - listitem:
+    - text: 人设 助手的身份与语气
+    - switch "启用 人设" [checked]
+  - listitem:
+    - text: 项目指令 读取 AGENTS.md 等项目说明
+    - switch "启用 项目指令" [checked]
+  - listitem:
+    - text: 终端 运行 shell 命令
+    - switch "启用 终端" [checked]
+  - listitem:
+    - text: PowerShell 终端 在 Windows 上运行 PowerShell
+    - switch "启用 PowerShell 终端" [disabled]
+  - listitem:
+    - text: 文件读写 读取、创建和修改文件
+    - switch "启用 文件读写" [checked]
+  - listitem:
+    - text: 文件搜索 按名称和内容查找文件
+    - switch "启用 文件搜索" [checked]
+  - listitem:
+    - text: 后台任务 启动和查看长时间运行的命令
+    - switch "启用 后台任务" [checked]
+  - listitem:
+    - text: 技能 从本地目录加载技能
+    - switch "启用 技能" [checked]
+  - listitem:
+    - text: 技能调用 让助手调用技能
+    - switch "启用 技能调用" [checked]
+  - listitem:
+    - text: 目标命令 /goal 命令
+    - switch "启用 目标命令" [checked]
+  - listitem:
+    - text: 目标 记录并跟踪当前目标
+    - switch "启用 目标" [checked]
+  - listitem:
+    - text: 计划模式 先规划再动手
+    - switch "启用 计划模式" [checked]
+  - listitem:
+    - text: 上下文压缩 对话过长时自动整理
+    - switch "启用 上下文压缩" [checked]
+  - listitem:
+    - text: 压缩命令 /compact 命令
+    - switch "启用 压缩命令" [checked]
+  - listitem:
+    - text: 工具结果裁剪 丢弃过时的工具输出
+    - switch "启用 工具结果裁剪" [checked]
+  - listitem:
+    - text: 子代理控制 查看和管理子代理
+    - switch "启用 子代理控制" [checked]
+  - listitem:
+    - text: 子代理列表 列出正在运行的子代理
+    - switch "启用 子代理列表" [checked]
+  - listitem:
+    - text: 子代理 把任务分派给并行的助手
+    - switch "启用 子代理" [checked]
+  - listitem:
+    - text: 分支子代理 复制当前上下文继续任务
+    - switch "启用 分支子代理" [checked]
+  - listitem:
+    - text: Codex 子代理 把任务交给 Codex 执行
+    - switch "启用 Codex 子代理" [disabled]
+  - listitem:
+    - text: Claude Code 子代理 把任务交给 Claude Code 执行
+    - switch "启用 Claude Code 子代理" [disabled]
+  - listitem:
+    - text: 工作流引擎 在后台线程运行工作流
+    - switch "启用 工作流引擎" [checked]
+  - listitem:
+    - text: 工作流 编排多步骤任务
+    - switch "启用 工作流" [checked]
+  - listitem:
+    - text: Ralph 循环 反复执行直到完成
+    - switch "启用 Ralph 循环" [checked]
+  - listitem:
+    - text: 向用户提问 需要时向你确认
+    - switch "启用 向用户提问" [checked]
+  - listitem:
+    - text: 待办 维护任务清单
+    - switch "启用 待办" [checked]
+  - listitem:
+    - text: 网页 搜索和抓取网页
+    - switch "启用 网页" [checked]
+  - listitem:
+    - text: "示例插件 本地 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
+    - button "从这个预设删除 示例插件": 删除
+    - switch "启用 示例插件" [checked]
+- heading "设置" [level=3]
+- 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 - 0
apps/web/tests/plugin-config.e2e.ts

@@ -60,6 +60,7 @@ describe('web e2e: plugin configuration section', () => {
     await expect
       .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
       .toBe('true')
+    await dialog.getByRole('tab', { name: '插件配置', exact: true }).click()
     await expect
       .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
       .toBe('true')

+ 49 - 38
apps/web/tests/plugin-manager.e2e.ts

@@ -19,6 +19,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
 const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
+const DETAIL_EXPECTED = join(SNAPSHOT_DIR, 'preset-detail.expected.md')
 const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
 const MODE = webSnapshotMode()
 
@@ -73,12 +74,27 @@ describe('web e2e: plugin manager', () => {
     return dialog
   }
 
+  /** Open the settings dialog on one preset's detail page. */
+  async function openPresetDetail(name: string) {
+    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: 'Agent 预设', exact: true }).click()
+    await dialog.getByRole('button', { name: `配置: ${name}` }).click()
+    await dialog.getByRole('heading', { name }).waitFor({ timeout: 10_000 })
+    return dialog
+  }
+
   /** One file under the harness home, or the empty string while it does not exist. */
   async function homeFile(...segments: string[]): Promise<string> {
     return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
   }
 
-  it('lists the profile packages with their switches and the preset composition', async () => {
+  it('lists the profile packages with their switches', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
     const dialog = await openPluginsTab('插件管理')
 
@@ -95,10 +111,6 @@ describe('web e2e: plugin manager', () => {
     await dialog.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
     await dialog.getByRole('button', { name: '收起 示例插件' }).click()
     await expect.poll(() => dialog.getByRole('button', { name: '卸载 示例插件' }).count(), { timeout: 5_000 }).toBe(0)
-    // The default preset's composition renders beside the packages under the
-    // harness dictionary's names.
-    expect(await dialog.getByRole('button', { name: '选择要管理的 Agent 预设' }).textContent()).toContain('默认')
-    expect(await dialog.getByRole('switch', { name: '启用 终端' }).count()).toBe(1)
 
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
@@ -127,7 +139,7 @@ describe('web e2e: plugin manager', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('adds a plain plugin to a preset, switches its row off there, and removes it again', async () => {
+  it('adds a plain plugin to a preset, then switches it off and deletes it on the preset\'s detail page', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preset-row'))
     const dialog = await openPluginsTab('插件管理')
     await dialog.getByRole('button', { name: '加入到…' }).click()
@@ -136,50 +148,52 @@ describe('web e2e: plugin manager', () => {
     const overlay = (): Promise<string> => readFile(join(presetRoot, 'standard', 'cordis.patch.yml'), 'utf8').catch(() => '')
     await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(true)
     expect(await overlay()).toContain('id: fixture/plain-plugin')
-    // The preset composition lists the new row under the package's title as
-    // the user's, switched on, with the local mark; the **Add to…** menu
-    // now marks that preset as joined.
+    // The **Add to…** menu now marks that preset as joined. Escape would
+    // close the settings dialog as well, so the menu closes through its anchor.
+    await dialog.getByRole('button', { name: '加入到…' }).click()
+    await page.getByRole('menuitem', { name: '预设:标准模式(已加入)' }).waitFor({ timeout: 5_000 })
+    await dialog.getByRole('button', { name: '加入到…' }).click()
+    await expect.poll(() => page.getByRole('menuitem').count(), { timeout: 5_000 }).toBe(0)
+
+    // The preset's detail page, behind the gear on its card, lists the new
+    // row under the package's title as the user's, switched on, with the
+    // local mark.
+    await dialog.getByRole('button', { name: 'Agent 预设', exact: true }).click()
+    await dialog.getByRole('button', { name: '配置: 标准模式' }).click()
+    await dialog.getByRole('heading', { name: '标准模式' }).waitFor({ timeout: 10_000 })
     const presetRow = dialog.locator('[data-preset-row="fixture/plain-plugin"]')
     const rowToggle = presetRow.getByRole('switch', { name: '启用 示例插件' })
     await rowToggle.waitFor({ timeout: 10_000 })
     expect(await rowToggle.getAttribute('aria-checked')).toBe('true')
     expect(await presetRow.getAttribute('data-plugin-source')).toBe('user')
     expect(await presetRow.getByText('本地', { exact: true }).count()).toBe(1)
-    // Escape would close the settings dialog as well, so the menu closes
-    // through its own anchor.
-    await dialog.getByRole('button', { name: '加入到…' }).click()
-    await page.getByRole('menuitem', { name: '预设:标准模式(已加入)' }).waitFor({ timeout: 5_000 })
-    await dialog.getByRole('button', { name: '加入到…' }).click()
-    await expect.poll(() => page.getByRole('menuitem').count(), { timeout: 5_000 }).toBe(0)
+    const detailSnapshot = await captureStableAria(page, '[data-preset-detail="standard"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(DETAIL_EXPECTED, detailSnapshot, MODE)
 
     await rowToggle.click()
     await expect.poll(async () => (await overlay()).includes('disabled: true'), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false')
 
-    await presetRow.getByRole('button', { name: '从这个预设移出 示例插件' }).click()
+    await presetRow.getByRole('button', { name: '从这个预设删除 示例插件' }).click()
     await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(false)
     await expect.poll(() => rowToggle.count(), { timeout: 10_000 }).toBe(0)
+    // The breadcrumb leads back to the roster.
+    await dialog.getByRole('button', { name: '返回 Agent 预设' }).click()
+    await dialog.getByRole('button', { name: '配置: 标准模式' }).waitFor({ timeout: 10_000 })
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('writes a configuration field under one preset scope and leaves the shared value alone', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-scope'))
-    const dialog = await openPluginsTab('插件配置')
-    const scope = dialog.getByRole('button', { name: '选择这些设置生效的 Agent 预设' })
-    await scope.waitFor({ timeout: 10_000 })
-    expect(await scope.textContent()).toBe('所有预设')
-
-    await scope.click()
-    await page.getByRole('menuitem', { name: '标准模式(默认)' }).click()
-    await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('preset/standard')
-    await dialog.getByText('这里的值只对本预设生效;未覆盖的字段继承"所有预设"的值。').waitFor({ timeout: 5_000 })
-
-    await dialog.getByText('终端', { exact: true }).click()
-    const timeout = dialog.getByLabel('命令超时(毫秒)')
+    const dialog = await openPresetDetail('标准模式')
+    // The detail page's settings section edits the preset's own scope.
+    const settingsSection = dialog.locator('[data-settings-scope="preset/standard"]')
+    await settingsSection.getByRole('button', { name: '展开设置: 终端' }).click()
+    const timeout = settingsSection.getByLabel('命令超时(毫秒)')
     await timeout.waitFor({ timeout: 10_000 })
     expect(await timeout.inputValue()).toBe('60000')
     await timeout.fill('12000')
-    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await settingsSection.getByRole('button', { name: '保存', exact: true }).click()
 
     const settings = (): Promise<string> => homeFile('settings.yaml')
     await expect.poll(async () => (await settings()).includes('timeoutMs: 12000'), { timeout: 10_000 }).toBe(true)
@@ -187,19 +201,16 @@ describe('web e2e: plugin manager', () => {
     expect(document).toContain('scopes:')
     expect(document).toContain('preset/standard:')
     expect(document.indexOf('scopes:')).toBeLessThan(document.indexOf('timeoutMs: 12000'))
-    // Back under the shared instance the field still shows the composed default.
-    await scope.click()
-    await page.getByRole('menuitem', { name: '所有预设' }).click()
-    await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('global')
-    const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
-    if (await expandTerminal.count() > 0) await expandTerminal.click()
-    await expect.poll(() => dialog.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('60000')
-    expect(await dialog.getByText('已覆盖').count()).toBe(0)
+    // On the configuration tab the shared instance still shows the composed default.
+    const plugins = await openPluginsTab('插件配置')
+    await plugins.getByRole('button', { name: '展开设置: 终端' }).click()
+    await expect.poll(() => plugins.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('60000')
+    expect(await plugins.getByText('已覆盖').count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'preset-detail.expected.md'])
   })
 })

+ 2 - 2
packages/client/ui-agent-preset/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-agent-preset/README.md
-README.md: 06f2bc703633069a40b4677d4d84c12f4cc2444c
-README.zh.md: a9fecb68fd4cddd192a02667e58133e696e345c8
+README.md: 5db0c7fc1e96371985ca8d5ce850e4a900064578
+README.zh.md: d1ff85696cd1705fb08388ca917b04a260dc5dd4

+ 2 - 2
packages/client/ui-agent-preset/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition.
+This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, the way into a preset's own files, and a gear on every card that opens the preset's detail page, where other plugins contribute what they know about it through the `settings.agentPreset.detail` slot: the plugin manager's Capabilities section and the plugin configuration's Settings section. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition.
 
 ## Table of Contents
 
@@ -29,7 +29,7 @@ Mount this plugin alongside the settings and conversation packages; the preset s
 
 ### Managing the roster
 
-The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
+The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. Every card that composes carries a gear: it opens the preset's detail page — a breadcrumb back to the roster, the name, marks, and description, then the sections registered into `settings.agentPreset.detail`, each handed the preset's id and display name and separated by the page; a broken preset has nothing to configure, so its gear is disabled, and a preset that leaves the roster while open drops back to the cards. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
 
 ### The conversational entry
 

+ 2 - 2
packages/client/ui-agent-preset/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。
+本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值、通往 preset 自身文件的入口,以及每张卡片上打开该 preset 详情页的齿轮,其他插件经 `settings.agentPreset.detail` 槽位把自己知道的内容贡献到那里:插件管理器的能力段与插件配置的设置段。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。
 
 ## 目录
 
@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 管理名单
 
-设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一表面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为提示条挂在徽标上,另有一个视觉隐藏的 alert 把它送达辅助技术,而被禁用的卡片主体做不到这一点。
+设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一表面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。每张可组装的卡片都带一个齿轮:打开该 preset 的详情页——返回名单的面包屑、名字、标记与描述,然后是注册进 `settings.agentPreset.detail` 的各段,每段拿到 preset 的 id 与显示名,由页面隔开;损坏的 preset 无可配置,其齿轮禁用;详情打开期间 preset 离开名单则回到卡片。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为提示条挂在徽标上,另有一个视觉隐藏的 alert 把它送达辅助技术,而被禁用的卡片主体做不到这一点。
 
 ### 对话式入口
 

+ 76 - 0
packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css

@@ -466,3 +466,79 @@
   opacity: 0.4;
   cursor: default;
 }
+
+/* The detail page: a breadcrumb back to the roster, the preset's name and
+   marks, then the sections other plugins contribute. */
+.crumb {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  border: 0;
+  padding: 0;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+  font: inherit;
+  font-size: 13px;
+  line-height: 20px;
+  cursor: pointer;
+}
+
+.crumb:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumb:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.crumbIcon {
+  transform: rotate(90deg);
+}
+
+.crumbSep::before {
+  content: '›';
+}
+
+.crumbHere {
+  color: var(--dsw-alias-label-primary);
+  font-weight: 600;
+}
+
+.detailHead {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 12px 0 4px;
+}
+
+.detailHead .inUse {
+  margin-left: 0;
+}
+
+.detailTitle {
+  margin: 0;
+  font-size: 20px;
+  line-height: 28px;
+  font-weight: 600;
+}
+
+.detailDesc {
+  margin: 0 0 20px;
+  max-width: 64ch;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 14px;
+  line-height: 22px;
+}
+
+/* The sections other plugins contribute to the detail page, one under another. */
+.detailSections {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+}
+
+.detailSections > * + * {
+  padding-top: 18px;
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}

+ 46 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx

@@ -13,12 +13,15 @@
 import { useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import {
-  Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
+  Button, IconBrowseOutline16, IconChevronDownOutline14, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16,
+  IconSettingsOutline16, IconTrashOutline16, Modal, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
-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 { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
 import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
+// The detail page's child slot, declared by this section at registration.
+import type {} from './slot-contract.ts'
 import css from './AgentPresetSection.module.css'
 
 /** Registration-side business face for the management section. */
@@ -63,6 +66,7 @@ export interface AgentPresetSectionInjected {
 export type AgentPresetSectionProps =
   PropsRuntime<'settings.section'>
   & PropsLocale<'settings.agentPreset'>
+  & PropsRenderSlots<'settings.agentPreset.detail'>
   & InjectFace<AgentPresetSectionInjected>
 
 /** Copy-dialog sub-view props: the draft plus the actions that mutate it. */
@@ -178,6 +182,10 @@ function CardDescription({ text }: { text: string }): ReactNode {
 export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
   const { useAgentPresetSection, t, load } = props
   const state = useAgentPresetSection(snapshot => snapshot)
+  // The preset whose detail page is open; a preset that leaves the roster
+  // while open (deleted from its files) drops back to the cards.
+  const [openId, setOpenId] = useState<string | null>(null)
+  const openRow = openId === null ? undefined : state.rows.find(row => row.id === openId)
   const viewedId = state.view?.id
   const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId)
   const viewedTitle = state.view === null
@@ -227,6 +235,29 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
     )
     : null
 
+  if (openRow !== undefined) {
+    const text = presetDisplayText(openRow, t)
+    return (
+      <div className={css.section} data-preset-detail={openRow.id}>
+        <button type="button" className={css.crumb} aria-label={t('backToRoster')} onClick={() => { setOpenId(null) }}>
+          <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
+          <span>{t('nav')}</span>
+          <span className={css.crumbSep} aria-hidden="true" />
+          <span className={css.crumbHere}>{text.name}</span>
+        </button>
+        <div className={css.detailHead}>
+          <h2 className={css.detailTitle}>{text.name}</h2>
+          <span className={css.badge}>{openRow.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
+          {openRow.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
+        </div>
+        <p className={css.detailDesc}>{text.description ?? t('noDescription')}</p>
+        <div className={css.detailSections}>
+          {props.renderSlot('settings.agentPreset.detail', { presetId: openRow.id, presetName: text.name })}
+        </div>
+      </div>
+    )
+  }
+
   return (
     <div className={css.section}>
       <h2 className={css.title}>{t('nav')}</h2>
@@ -312,6 +343,19 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
                       <code className={css.cardId}>{row.id}</code>
                     </button>
                     <div className={css.cardFoot}>
+                      {/* The way into the preset's detail page — its
+                        capabilities and settings; a broken preset composes
+                        nothing, so there is nothing to configure. */}
+                      <button
+                        type="button"
+                        className={css.iconButton}
+                        disabled={row.broken !== undefined}
+                        data-tip={t('configure')}
+                        aria-label={`${t('configure')}: ${text.name}`}
+                        onClick={() => { setOpenId(row.id) }}
+                      >
+                        <IconSettingsOutline16 />
+                      </button>
                       {/* Shipped presets are the compositions a copy starts
                         from, so READING one is the point; a custom preset is
                         edited in its files instead, which the location action

+ 5 - 0
packages/client/ui-agent-preset/src/client/index.ts

@@ -43,6 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
   }
 }
 
+export type { AgentPresetDetailOwnerProps } from './slot-contract.ts'
 export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx'
 export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
 export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
@@ -200,5 +201,9 @@ export function apply(ctx: ClientContext): void {
     label: () => ctx.locale.bind('settings.agentPreset')('nav'),
     locale: 'settings.agentPreset',
     inject: sectionInjected,
+    // A preset's detail page is assembled from sections other plugins
+    // contribute (the plugin manager's capabilities list); the roster only
+    // names the preset.
+    children: { 'settings.agentPreset.detail': { kind: 'list', scope: 'root' } },
   }, AgentPresetSection))
 }

+ 5 - 0
packages/client/ui-agent-preset/src/client/locales.ts

@@ -17,6 +17,7 @@ export type AgentPresetSettingsKey =
   | 'openLocation' | 'showLocation' | 'revealedPathLabel'
   | 'idRequired' | 'idInvalid' | 'idTaken'
   | 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
+  | 'configure' | 'backToRoster'
 
 /** English copy. */
 export const en: Record<AgentPresetSettingsKey, string> = {
@@ -80,6 +81,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
     'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
   deleteConfirm: 'Delete',
   deleting: 'Deleting…',
+  configure: 'Configure',
+  backToRoster: 'Back to agent presets',
 }
 
 /** Simplified Chinese copy. */
@@ -135,6 +138,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
   deleteConfirm: '删除',
   deleting: '正在删除…',
+  configure: '配置',
+  backToRoster: '返回 Agent 预设',
 }
 
 // The resolution itself is the shared fold in `dsh-agent-presets/display`,

+ 25 - 0
packages/client/ui-agent-preset/src/client/slot-contract.ts

@@ -0,0 +1,25 @@
+/**
+ * The `settings.agentPreset.detail` slot type — the sections of one preset's
+ * detail page under the Agent presets settings section, rendered in
+ * registration order once a person opens a preset's configuration from its
+ * card. The owner share names the preset; a registrant renders what it knows
+ * about that preset (the plugin manager contributes the preset's capabilities).
+ *
+ * TYPE HOME RATIONALE: the section declares this slot at runtime, and a
+ * plugin registering a detail section already depends on this package for the
+ * declaration. The type therefore lives with its declarer.
+ */
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /** One section of a preset's detail page (see module JSDoc). */
+    'settings.agentPreset.detail': { kind: 'list'; scope: 'root'; owner: AgentPresetDetailOwnerProps }
+  }
+}
+
+/** Owner share of a detail section: which preset the page shows. */
+export interface AgentPresetDetailOwnerProps {
+  /** The preset's id. */
+  readonly presetId: string
+  /** The preset's display name, resolved through the roster dictionaries. */
+  readonly presetName: string
+}

+ 5 - 0
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -197,6 +197,11 @@ describe('ui-agent-preset apply', () => {
     expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 })
     // The nav label is a locale-following thunk; owners resolve it at read time.
     expect(resolveSlotLabel(section.options.label)).toBe('Agent 预设')
+    // The section declares the detail page's child slot: a contribution to it
+    // registers instead of throwing for an undeclared key.
+    const probe = slots.register({ name: 'settings.agentPreset.detail', id: 'probe' } as never, () => null)
+    expect(slots.entries('settings.agentPreset.detail')).toHaveLength(1)
+    probe()
   })
 
   it('registers into a declaration that arrives after apply', async () => {

+ 39 - 1
packages/client/ui-agent-preset/tests/section.client.spec.tsx

@@ -59,6 +59,8 @@ function renderSection(
     confirmDelete: vi.fn(),
     remove: vi.fn(() => Promise.resolve()),
     makeDefault: vi.fn(() => Promise.resolve()),
+    // The detail page's child slot, rendered by the shell in the real tree.
+    renderSlot: vi.fn(() => <p>detail-slot</p>),
   }
   const props = {
     ...actions,
@@ -66,7 +68,7 @@ function renderSection(
     t: (key: keyof typeof en) => en[key],
   } as unknown as AgentPresetSectionProps
   render(<AgentPresetSection {...props} />)
-  return actions
+  return { ...actions, store }
 }
 
 /** Locate a card by the id it prints, not by its display name. */
@@ -531,3 +533,39 @@ describe('a long card description', () => {
     expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
   })
 })
+
+describe('preset detail', () => {
+  it('opens a preset from its gear, names it, renders the contributed sections, and returns through the breadcrumb', () => {
+    const actions = renderSection()
+
+    fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.configure}: ${en.presetStandardName}` }))
+    expect(screen.getByRole('heading', { name: en.presetStandardName })).toBeTruthy()
+    expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
+    expect(screen.getByText(en.inUse)).toBeTruthy()
+    expect(screen.getByText('detail-slot')).toBeTruthy()
+    expect(actions.renderSlot).toHaveBeenCalledWith('settings.agentPreset.detail', { presetId: 'standard', presetName: en.presetStandardName })
+    // The roster is off screen while a detail is open.
+    expect(screen.queryByText(en.sectionIntro)).toBeNull()
+
+    fireEvent.click(screen.getByRole('button', { name: en.backToRoster }))
+    expect(screen.getByText(en.sectionIntro)).toBeTruthy()
+    expect(screen.queryByText('detail-slot')).toBeNull()
+  })
+
+  it('shows a custom preset without a description as such, drops back to the roster when it leaves, and withholds the gear on a broken one', () => {
+    const { store } = renderSection({
+      rows: [...READY.rows, { id: 'broken', trust: 'user', isDefault: false, broken: 'bad yaml' }],
+    })
+    expect(within(rowFor('broken')).getByRole('button', { name: `${en.configure}: broken` })).toHaveProperty('disabled', true)
+
+    fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.configure}: mine` }))
+    expect(screen.getByRole('heading', { name: 'mine' })).toBeTruthy()
+    expect(screen.getByText(en.noDescription)).toBeTruthy()
+    expect(screen.getByText(en.userTrust)).toBeTruthy()
+    expect(screen.queryByText(en.inUse)).toBeNull()
+
+    act(() => { store.set({ ...store.getSnapshot(), rows: [READY.rows[0]!] }) })
+    expect(screen.getByText(en.sectionIntro)).toBeTruthy()
+    expect(screen.queryByText('detail-slot')).toBeNull()
+  })
+})

+ 2 - 2
packages/client/ui-settings-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-settings-plugin-manager/README.md
-README.md: a9779df58f5baa3a7525b3c456d564648c313bcc
-README.zh.md: 3fd6ec9c82947e61ceec76f5fdbbe0ae27556db7
+README.md: 2dcb93f680bf6cd7fb8aa421ebbadb0438a1302c
+README.zh.md: 3781fe530fb990b8640841c18944473d5bd6239d

+ 3 - 3
packages/client/ui-settings-plugin-manager/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns without showing them: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per package with its display name and its one-line description; a pack carries its switch, a plugin its **Add to…** menu, a built-in pack a *Built-in* mark and a locked switch, and a card is tagged only when a restart is pending or something is wrong. An expanded card shows the version, the source, a pack's components with their state, and **Uninstall**; entry ids, module names, kinds, and probe facts stay off the page. The **Session plugins** group shows the selected preset's composition as the same cards, harness modules named through the tab's dictionary and installed ones through their manifest with a *Local* mark, with a switch per card and **Remove** for rows the user added. The install dialog takes a package name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
+`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns without showing them: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per package with its display name and its one-line description; a pack carries its switch, a plugin its **Add to…** menu, a built-in pack a *Built-in* mark and a locked switch, and a card is tagged only when a restart is pending or something is wrong. An expanded card shows the version, the source, a pack's components with their state, and **Uninstall**; entry ids, module names, kinds, and probe facts stay off the page. A preset's composition is not on this tab: the package contributes it to the preset's detail page in the Agent presets section as the **Capabilities** section — the same cards, harness modules named through the tab's dictionary and installed ones through their manifest with a *Local* mark, a switch per card, **Delete** for rows the user added, and an **Add** menu. The install dialog takes a package name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
 
 ## Table of Contents
 
@@ -37,7 +37,7 @@ A pack's switch calls `plugins.enable` or `plugins.disable`. On a live-reload pr
 
 ### Adding a plugin to a preset
 
-A plugin's card carries **Add to…**, listing every session and each preset; a target the plugin already joined is marked and disabled, and a package that declares several importable modules nests the targets under each module. The choice writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. The session group's preset switcher shows one preset's composition as cards: a card's switch writes `disabled: true` into that preset's user patch layer or removes the key again — a row the preset itself switched off stays locked, because the layer can only deny — and a row the user added carries **Remove** beside its switch. Running and off are what the switch shows; no card tags them. Rows that declare no id cannot be switched here.
+A plugin's card carries **Add to…**, listing every session and each preset; a target the plugin already joined is marked and disabled, and a package that declares several importable modules nests the targets under each module. The choice writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. A preset's composition is managed on the preset's own detail page in the Agent presets section, where this package contributes the **Capabilities** section: one card per row with its switch, **Delete** on a row the person added, and an **Add** menu over the installed modules the preset does not carry yet. A switch writes `disabled: true` into that preset's user patch layer or removes the key again — a row the preset itself switched off stays locked, because the layer can only deny. Running and off are what the switch shows; no card tags them. Rows that declare no id cannot be switched.
 
 ### Reading a failure
 
@@ -53,7 +53,7 @@ The last action's outcome sits above the groups: a restart notice, a done notice
 
 ### Registration
 
-The browser plugin registers one localized `settings.plugins.tab` contribution with id `manage` and order 5, after the configuration tab. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries. Harness modules in a preset resolve their display name and one-liner through the tab's own dictionary — `name.<slug>` and `desc.<slug>`, tried by the composition row id first (four subagent rows share one module) and then by the unscoped module name without its `dsh-` prefix — and only for modules under the `@deepseek-ai/` scope; a third-party module reads its installed package's `dsh.title` and `description`, or an addable module's own title.
+The browser plugin registers one localized `settings.plugins.tab` contribution with id `manage` and order -10, before the configuration tab, and one `settings.agentPreset.detail` contribution with id `plugins` — the Capabilities section of every preset's detail page, over the same store. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries. Harness modules in a preset resolve their display name and one-liner through the tab's own dictionary — `name.<slug>` and `desc.<slug>`, tried by the composition row id first (four subagent rows share one module) and then by the unscoped module name without its `dsh-` prefix — and only for modules under the `@deepseek-ai/` scope; a third-party module reads its installed package's `dsh.title` and `description`, or an addable module's own title.
 
 ### The store
 

+ 3 - 3
packages/client/ui-settings-plugin-manager/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词但不把它们显示出来:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个包列出一张卡片,带显示名与一句话描述;插件包带开关,插件带**加入到…**菜单,内置插件包带*内置*标记与锁定的开关,只有需重启或异常时卡片才打标签。展开的卡片显示版本、来源、插件包的组件及各自状态,以及**卸载**;entry id、模块名、种类与探针事实不出现在页面上。**会话插件**组把选中预设的组合显示成同样的卡片,harness 模块按标签页字典命名,自己装的模块按 manifest 命名并带*本地*标记,每张卡片一个开关,用户添加的行还有**移出**。安装对话框接受包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
+`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词但不把它们显示出来:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个包列出一张卡片,带显示名与一句话描述;插件包带开关,插件带**加入到…**菜单,内置插件包带*内置*标记与锁定的开关,只有需重启或异常时卡片才打标签。展开的卡片显示版本、来源、插件包的组件及各自状态,以及**卸载**;entry id、模块名、种类与探针事实不出现在页面上。预设的组合不在这个标签页上:本包把它作为**能力**段贡献到 Agent 预设分区里该预设的详情页——同样的卡片,harness 模块按标签页字典命名,自己装的模块按 manifest 命名并带*本地*标记,每张卡片一个开关,用户添加的行带**删除**,段头有**添加**菜单。安装对话框接受包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
 
 ## 目录
 
@@ -37,7 +37,7 @@ kind: "package-reference"
 
 ### 把插件加入预设
 
-插件卡片带**加入到…**,列出所有会话与每个预设;已加入的目标被标注并置灰,声明了多个可导入模块的包把目标嵌在各模块之下。选择后通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。会话组的预设切换器把某个预设的组合显示成卡片:卡片的开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键——预设自己关掉的行保持锁定,因为该层只能拒绝——用户添加的行在开关旁带**移出**。运行中与已停用由开关表达,卡片不为此打标签。没有声明 id 的行不能在这里切换。
+插件卡片带**加入到…**,列出所有会话与每个预设;已加入的目标被标注并置灰,声明了多个可导入模块的包把目标嵌在各模块之下。选择后通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。预设的组合在 Agent 预设分区里该预设自己的详情页上管理,本包向那里贡献**能力**段:每行一张带开关的卡片,用户添加的行带**删除**,段头的**添加**菜单列出预设还没有的已安装模块。开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键——预设自己关掉的行保持锁定,因为该层只能拒绝。运行中与已停用由开关表达,卡片不为此打标签。没有声明 id 的行不能切换。
 
 ### 阅读失败
 
@@ -53,7 +53,7 @@ kind: "package-reference"
 
 ### 注册
 
-浏览器插件注册一个 id 为 `manage`、order 为 5 的本地化 `settings.plugins.tab` 贡献,位于配置标签页之后。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。预设名经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。预设里的 harness 模块经标签页自己的字典解析显示名与一句话——`name.<slug>` 与 `desc.<slug>`,先按组合行 id 查(四个子代理行共用一个模块),再按去掉 `dsh-` 前缀的无 scope 模块名查——且只对 `@deepseek-ai/` scope 下的模块生效;第三方模块读取其安装包的 `dsh.title` 与 `description`,或可添加模块自己的标题。
+浏览器插件注册一个 id 为 `manage`、order 为 -10 的本地化 `settings.plugins.tab` 贡献,位于配置标签页之前;另注册一个 id 为 `plugins` 的 `settings.agentPreset.detail` 贡献——每个预设详情页的能力段,读同一个 store。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。预设名经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。预设里的 harness 模块经标签页自己的字典解析显示名与一句话——`name.<slug>` 与 `desc.<slug>`,先按组合行 id 查(四个子代理行共用一个模块),再按去掉 `dsh-` 前缀的无 scope 模块名查——且只对 `@deepseek-ai/` scope 下的模块生效;第三方模块读取其安装包的 `dsh.title` 与 `description`,或可添加模块自己的标题。
 
 ### store
 

+ 27 - 0
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css

@@ -366,6 +366,33 @@
   gap: 8px;
 }
 
+/* The delete of a row the person added: red on its own, not only on hover. */
+.deleteButton {
+  border: 0;
+  border-radius: 8px;
+  padding: 4px 8px;
+  background: transparent;
+  color: var(--dsw-alias-state-error-primary);
+  font: inherit;
+  font-size: 12.5px;
+  line-height: 18px;
+  cursor: pointer;
+}
+
+.deleteButton:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover));
+}
+
+.deleteButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.deleteButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
 .addButton,
 .linkButton {
   border: 0.5px solid var(--dsw-alias-border-l3);

+ 38 - 258
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -2,12 +2,12 @@
  * The Manage plugins tab: the profile's installed packages as cards — a
  * plugin pack with its switch, a plugin with its **Add to…** menu, a built-in
  * pack with a locked switch — each a name, a one-liner, and a tag only when
- * a restart is pending or something is wrong; the selected agent preset's
- * composition as the same cards with one switch each; the install dialog
- * streaming pnpm's output behind a fold; and the confirmation a destructive
- * action waits on, naming what still uses the package. Entry ids, module
- * names, kinds, and probe facts stay off the page; an expanded card shows
- * the version, the source, a pack's components, and its uninstall.
+ * a restart is pending or something is wrong; the install dialog streaming
+ * pnpm's output behind a fold; and the confirmation a destructive action
+ * waits on, naming what still uses the package. Entry ids, module names,
+ * kinds, and probe facts stay off the page; an expanded card shows the
+ * version, the source, a pack's components, and its uninstall. A preset's
+ * composition lives on the preset's own detail page (`PresetPluginsSection`).
  */
 
 import { useEffect, useId, useState, type ReactNode } from 'react'
@@ -17,11 +17,9 @@ import {
   Button, IconChevronDownOutline14, IconRefreshOutline16, Menu, Modal, type MenuItem,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import { zh, type PluginManagerLocaleKey } from './locales.ts'
-import type {
-  ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PresetGroup, PresetRow,
-} from './manager-store.ts'
-import { rowKey } from './manager-store.ts'
+import type { PluginManagerLocaleKey } from './locales.ts'
+import type { ConfirmState, InstallState, PluginManagerFace, PresetGroup } from './manager-store.ts'
+import { noticeText, packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
 import css from './PluginManagerSettingsTab.module.css'
 
 /** Full component props assembled by the Settings slot renderer. */
@@ -30,7 +28,6 @@ export type PluginManagerSettingsTabProps =
   & PropsLocale<'settings.pluginManager'>
   & InjectFace<PluginManagerFace>
 
-type Translate = PluginManagerSettingsTabProps['t']
 type RowView = PluginPackageView['rows'][number]
 type RowPhase = NonNullable<RowView['phase']>
 
@@ -60,86 +57,6 @@ const STATUS_KEYS = {
   problem: 'statusProblem',
 } satisfies Record<CardStatus, PluginManagerLocaleKey>
 
-/** The scope every harness module is published under. */
-const FIRST_PARTY_SCOPE = '@deepseek-ai/'
-
-/** Compact a package name to what a person calls it. */
-function shortName(name: string): string {
-  const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
-  return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
-}
-
-/** The dictionary key for one slug, when the dictionary carries it. */
-function copyKey(kind: 'name' | 'desc', slug: string): PluginManagerLocaleKey | undefined {
-  const key = `${kind}.${slug}`
-  return key in zh ? key as PluginManagerLocaleKey : undefined
-}
-
-/**
- * Display copy of one harness module: by the composition row id first (four
- * subagent rows share one module), then by the unscoped module name without
- * its `dsh-` prefix. Every `name.<slug>` key has its `desc.<slug>` partner.
- * Undefined for a module outside the harness scope or one the dictionary
- * does not name.
- */
-function harnessCopy(t: Translate, moduleName: string, rowId: string | null): { title: string; description: string } | undefined {
-  if (!moduleName.startsWith(FIRST_PARTY_SCOPE)) return undefined
-  const slugs = [...rowId === null ? [] : [rowId], moduleName.slice(FIRST_PARTY_SCOPE.length).replace(/^dsh-/, '')]
-  for (const slug of slugs) {
-    const name = copyKey('name', slug)
-    if (name === undefined) continue
-    return { title: t(name), description: t(`desc.${slug}` as PluginManagerLocaleKey) }
-  }
-  return undefined
-}
-
-/** The installed package a module belongs to: the package itself or one of its subpaths. */
-function packageOf(moduleName: string, packages: readonly PluginPackageView[]): PluginPackageView | undefined {
-  return packages.find(pkg => moduleName === pkg.name || moduleName.startsWith(`${pkg.name}/`))
-}
-
-/** The row id a tree entry id ends in: what a user layer addresses. */
-function rowIdOf(entryId: string | null): string | null {
-  return entryId === null ? null : entryId.slice(entryId.lastIndexOf(':') + 1)
-}
-
-/** What a person calls one tree entry: its harness name, else its row id. */
-function rowLabel(t: Translate, entryId: string): string {
-  const id = rowIdOf(entryId) as string
-  const key = copyKey('name', id)
-  return key === undefined ? id : t(key)
-}
-
-/** What one preset row shows: a title, a one-liner, and whether the person installed it. */
-function presetRowCopy(
-  row: PresetRow, packages: readonly PluginPackageView[], t: Translate,
-): { title: string; description?: string; local: boolean } {
-  const pkg = packageOf(row.moduleName, packages)
-  if (pkg !== undefined) {
-    const addable = pkg.addable.find(entry => entry.moduleName === row.moduleName)
-    return {
-      title: addable?.title ?? pkg.title ?? shortName(pkg.name),
-      ...pkg.description === undefined ? {} : { description: pkg.description },
-      local: pkg.trust === 'external',
-    }
-  }
-  const harness = harnessCopy(t, row.moduleName, rowIdOf(row.entryId))
-  if (harness !== undefined) return { ...harness, local: false }
-  return { title: shortName(row.moduleName), description: row.moduleName, local: !row.moduleName.startsWith(FIRST_PARTY_SCOPE) }
-}
-
-/** The roster row shown when the switcher has no explicit choice. */
-function fallbackPreset(presets: readonly PresetGroup[]): PresetGroup | undefined {
-  return presets.find(preset => preset.isDefault) ?? presets[0]
-}
-
-function presetLabel(preset: PresetGroup, t: Translate, presetName: (preset: PresetGroup) => string): string {
-  const name = presetName(preset)
-  if (preset.broken !== undefined) return t('presetOptionBroken', { name })
-  if (preset.isDefault) return t('presetOptionDefault', { name })
-  return name
-}
-
 /** Status dot naming a live root-fiber phase. */
 function PhaseDot({ phase, t }: { readonly phase: RowPhase; readonly t: Translate }): ReactNode {
   const label = t(PHASE_KEYS[phase])
@@ -375,57 +292,6 @@ function PackageCard({
   )
 }
 
-/** One row of the selected preset's composition, as a card with its switch. */
-function PresetCard({ preset, row, packages, t, busy, onSetDisabled, onRemove }: {
-  readonly preset: PresetGroup
-  readonly row: PresetRow
-  readonly packages: readonly PluginPackageView[]
-  readonly t: Translate
-  readonly busy: boolean
-  readonly onSetDisabled: (rowId: string, disabled: boolean) => void
-  readonly onRemove: (rowId: string) => void
-}): ReactNode {
-  const id = rowIdOf(row.entryId)
-  const copy = presetRowCopy(row, packages, t)
-  const lockedOff = row.enabled === false && row.disabledBy === 'composition'
-  return (
-    <li className={css.card} data-preset-row={row.entryId ?? undefined} data-plugin-source={row.source}>
-      <div className={css.cardHead}>
-        <div className={css.cardMain}>
-          <div className={css.titleRow}>
-            <span className={css.cardTitle}>{copy.title}</span>
-            {copy.local ? <Tag kind="local">{t('localTag')}</Tag> : null}
-            {row.fiberPhase === 'failed' ? <Tag kind="problem">{t('rowStateFailed')}</Tag> : null}
-          </div>
-          {copy.description === undefined ? null : <span className={css.cardDesc}>{copy.description}</span>}
-        </div>
-        <div className={css.cardEnd}>
-          {id === null
-            ? <span className={css.rowNote} title={t('rowNoId')}>{t('rowNoId')}</span>
-            : (
-              <>
-                {row.source === 'user'
-                  ? (
-                    <TextButton label={t('rowRemoveLabel', { name: copy.title })} disabled={busy} danger onClick={() => { onRemove(id) }}>
-                      {t('rowRemove')}
-                    </TextButton>
-                  )
-                  : null}
-                <Switch
-                  checked={row.enabled !== false}
-                  label={t('rowToggle', { name: copy.title })}
-                  disabled={busy || preset.broken !== undefined || lockedOff}
-                  {...lockedOff ? { title: t('rowLockedByComposition') } : {}}
-                  onChange={(checked) => { onSetDisabled(id, !checked) }}
-                />
-              </>
-            )}
-        </div>
-      </div>
-    </li>
-  )
-}
-
 /** The sentence for one package the Host removed again after the install. */
 function removedText(entry: PluginInstallRejection, t: Translate): string {
   return entry.reason.startsWith('row ')
@@ -564,40 +430,13 @@ function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, o
   )
 }
 
-function noticeText(notice: ManagerNotice, t: Translate): string {
-  switch (notice.kind) {
-    case 'restart': return t('restartNotice')
-    case 'done': return t('doneNotice')
-    case 'failed': {
-      switch (notice.code) {
-        case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason })
-        case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
-        case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
-        case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
-        default: return refusalText(notice, t)
-      }
-    }
-  }
-}
-
-/** The copy for a refusal every mutation can meet: the manager is busy, or a session is running. */
-function refusalText(failure: { readonly code: string; readonly reason: string }, t: Translate): string {
-  switch (failure.code) {
-    case 'plugins/busy': return t('busy', { reason: failure.reason })
-    case 'plugins/agents-running': return t('agentsRunning', { reason: failure.reason })
-    default: return failure.code === 'plugins/install-failed' ? t('installFailed') : t('actionFailed', { reason: failure.reason })
-  }
-}
-
-/** Render the plugin manager: installed packages first, then the selected preset's composition. */
+/** Render the plugin manager: the installed packages, the install dialog, and the confirmation. */
 export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
   const { t, presetName, ensure } = props
   const state = props.usePluginManager(snapshot => snapshot)
   const [expanded, setExpanded] = useState<string | null>(null)
-  const [switcherOpen, setSwitcherOpen] = useState(false)
   useEffect(() => { ensure() }, [ensure])
 
-  const selected = state.presets.find(preset => preset.id === state.selectedPreset) ?? fallbackPreset(state.presets)
   const restartPending = state.packages.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
   const loaded = state.status === 'ready' || state.status === 'error'
 
@@ -632,94 +471,35 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
         )}
       {loaded
         ? (
-          <>
-            <section className={css.group} data-plugin-scope="global">
-              <div className={css.groupTitleRow}>
-                <h3 className={css.groupTitle}>{t('installedTitle')}</h3>
-                <span className={css.count} data-plugin-count={state.packages.length}>{`${String(state.packages.length)} ${t('countUnit')}`}</span>
-              </div>
-              {state.packages.length === 0
-                ? <p className={css.empty}>{t('empty')}</p>
-                : (
-                  <ul className={css.cards}>
-                    {state.packages.map(pkg => (
-                      <PackageCard
-                        key={pkg.name}
-                        pkg={pkg}
-                        t={t}
-                        busy={state.busy.includes(pkg.name)}
-                        open={expanded === pkg.name}
-                        presets={state.presets}
-                        globalModules={state.globalModules}
-                        presetName={presetName}
-                        onToggleOpen={() => { setExpanded(current => current === pkg.name ? null : pkg.name) }}
-                        onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
-                        onRetry={() => { props.retry(pkg.name) }}
-                        onUninstall={() => { props.uninstall(pkg.name) }}
-                        onAddRow={(declaredName, target) => { props.addRow(pkg.name, declaredName, target) }}
-                      />
-                    ))}
-                  </ul>
-                )}
-            </section>
-            <section className={css.group} data-plugin-scope="preset" data-preset-id={selected?.id}>
-              <div className={css.groupTitleRow}>
-                <h3 className={css.groupTitle}>{t('presetTitle')}</h3>
-                {selected === undefined
-                  ? null
-                  : (
-                    <Menu
-                      open={switcherOpen}
-                      onClose={() => { setSwitcherOpen(false) }}
-                      items={state.presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t, presetName) }))}
-                      selectedId={selected.id}
-                      onSelect={(id) => {
-                        setSwitcherOpen(false)
-                        props.selectPreset(id)
-                      }}
-                      align="end"
-                      portal
-                      anchor={(
-                        <button
-                          type="button"
-                          className={css.switcher}
-                          aria-haspopup="menu"
-                          aria-expanded={switcherOpen}
-                          aria-label={t('switcherLabel')}
-                          onClick={() => { setSwitcherOpen(value => !value) }}
-                        >
-                          <span className={css.switcherLabel}>{presetLabel(selected, t, presetName)}</span>
-                          <IconChevronDownOutline14 className={css.chevron} aria-hidden="true" />
-                        </button>
-                      )}
+          <section className={css.group} data-plugin-scope="global">
+            <div className={css.groupTitleRow}>
+              <h3 className={css.groupTitle}>{t('installedTitle')}</h3>
+              <span className={css.count} data-plugin-count={state.packages.length}>{`${String(state.packages.length)} ${t('countUnit')}`}</span>
+            </div>
+            {state.packages.length === 0
+              ? <p className={css.empty}>{t('empty')}</p>
+              : (
+                <ul className={css.cards}>
+                  {state.packages.map(pkg => (
+                    <PackageCard
+                      key={pkg.name}
+                      pkg={pkg}
+                      t={t}
+                      busy={state.busy.includes(pkg.name)}
+                      open={expanded === pkg.name}
+                      presets={state.presets}
+                      globalModules={state.globalModules}
+                      presetName={presetName}
+                      onToggleOpen={() => { setExpanded(current => current === pkg.name ? null : pkg.name) }}
+                      onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
+                      onRetry={() => { props.retry(pkg.name) }}
+                      onUninstall={() => { props.uninstall(pkg.name) }}
+                      onAddRow={(declaredName, target) => { props.addRow(pkg.name, declaredName, target) }}
                     />
-                  )}
-              </div>
-              <p className={css.groupSub}>{t('presetSubtitle')}</p>
-              {selected === undefined
-                ? <p className={css.empty}>{t('presetNoRoster')}</p>
-                : selected.broken !== undefined
-                  ? <p className={css.reason} role="alert">{selected.broken}</p>
-                  : selected.rows.length === 0
-                    ? <p className={css.empty}>{t('presetRowsEmpty')}</p>
-                    : (
-                      <ul className={css.cards}>
-                        {selected.rows.map((row, index) => (
-                          <PresetCard
-                            key={`${row.entryId ?? row.moduleName}:${String(index)}`}
-                            preset={selected}
-                            row={row}
-                            packages={state.packages}
-                            t={t}
-                            busy={row.entryId !== null && state.busy.includes(rowKey({ kind: 'preset', preset: selected.id }, rowIdOf(row.entryId) as string))}
-                            onSetDisabled={(rowId, disabled) => { props.setRowDisabled({ kind: 'preset', preset: selected.id }, rowId, disabled) }}
-                            onRemove={(rowId) => { props.removeRow({ kind: 'preset', preset: selected.id }, rowId) }}
-                          />
-                        ))}
-                      </ul>
-                    )}
-            </section>
-          </>
+                  ))}
+                </ul>
+              )}
+          </section>
         )
         : null}
       <InstallDialog

+ 196 - 0
packages/client/ui-settings-plugin-manager/src/client/PresetPluginsSection.tsx

@@ -0,0 +1,196 @@
+/**
+ * The capabilities section of one agent preset's detail page: the preset's
+ * composition as cards with a switch each, a delete for rows the person
+ * added, and an **Add** menu over the installed plugins the preset does not
+ * carry yet. Contributed to the roster's `settings.agentPreset.detail` slot;
+ * it reads the same store as the Manage plugins tab, so a change made on
+ * either surface shows on the other at once.
+ */
+
+import { useEffect, useState, type ReactNode } from 'react'
+import clsx from 'clsx'
+import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import { Menu, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: the detail slot's declaration, owned by the roster.
+import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
+import type { PluginManagerFace, PresetGroup, PresetRow } from './manager-store.ts'
+import { rowKey } from './manager-store.ts'
+import { noticeText, presetRowCopy, rowIdOf, shortName, type Translate } from './presentation.ts'
+import css from './PluginManagerSettingsTab.module.css'
+
+/** Full component props assembled by the slot renderer. */
+export type PresetPluginsSectionProps =
+  PropsRuntime<'settings.agentPreset.detail'>
+  & PropsLocale<'settings.pluginManager'>
+  & InjectFace<PluginManagerFace>
+
+/** One module the preset could still add, carried through the menu as its item id. */
+interface AddChoice {
+  readonly packageName: string
+  readonly declaredName: string
+}
+
+/** The installed modules the preset does not carry yet, as menu items. */
+function addChoices(packages: readonly PluginPackageView[], preset: PresetGroup, t: Translate): MenuItem[] {
+  const carried = new Set(preset.rows.map(row => row.moduleName))
+  const items: MenuItem[] = []
+  for (const pkg of packages) {
+    const title = pkg.title ?? shortName(pkg.name)
+    for (const entry of pkg.addable) {
+      if (!entry.ok || carried.has(entry.moduleName)) continue
+      items.push({
+        id: JSON.stringify({ packageName: pkg.name, declaredName: entry.declaredName } satisfies AddChoice),
+        label: entry.title ?? (entry.declaredName === '.' ? title : `${title} · ${entry.declaredName}`),
+      })
+    }
+  }
+  return items.length === 0 ? [{ id: 'none', label: t('capabilitiesAddEmpty'), disabled: true }] : items
+}
+
+function Tag({ kind, children }: { readonly kind: string; readonly children: ReactNode }): ReactNode {
+  return <span className={css.tag} data-kind={kind}>{children}</span>
+}
+
+/** One row of the preset's composition, as a card with its switch. */
+function PresetCard({ preset, row, packages, t, busy, onSetDisabled, onRemove }: {
+  readonly preset: PresetGroup
+  readonly row: PresetRow
+  readonly packages: readonly PluginPackageView[]
+  readonly t: Translate
+  readonly busy: boolean
+  readonly onSetDisabled: (rowId: string, disabled: boolean) => void
+  readonly onRemove: (rowId: string) => void
+}): ReactNode {
+  const id = rowIdOf(row.entryId)
+  const copy = presetRowCopy(row, packages, t)
+  const lockedOff = row.enabled === false && row.disabledBy === 'composition'
+  return (
+    <li className={css.card} data-preset-row={row.entryId ?? undefined} data-plugin-source={row.source}>
+      <div className={css.cardHead}>
+        <div className={css.cardMain}>
+          <div className={css.titleRow}>
+            <span className={css.cardTitle}>{copy.title}</span>
+            {copy.local ? <Tag kind="local">{t('localTag')}</Tag> : null}
+            {row.fiberPhase === 'failed' ? <Tag kind="problem">{t('rowStateFailed')}</Tag> : null}
+          </div>
+          {copy.description === undefined ? null : <span className={css.cardDesc}>{copy.description}</span>}
+        </div>
+        <div className={css.cardEnd}>
+          {id === null
+            ? <span className={css.rowNote} title={t('rowNoId')}>{t('rowNoId')}</span>
+            : (
+              <>
+                {row.source === 'user'
+                  ? (
+                    <button
+                      type="button"
+                      className={css.deleteButton}
+                      aria-label={t('rowRemoveLabel', { name: copy.title })}
+                      disabled={busy}
+                      onClick={() => { onRemove(id) }}
+                    >
+                      {t('rowRemove')}
+                    </button>
+                  )
+                  : null}
+                <button
+                  type="button"
+                  role="switch"
+                  aria-checked={row.enabled !== false}
+                  aria-label={t('rowToggle', { name: copy.title })}
+                  {...lockedOff ? { title: t('rowLockedByComposition') } : {}}
+                  className={clsx(css.switch, row.enabled !== false && css.switchOn)}
+                  disabled={busy || preset.broken !== undefined || lockedOff}
+                  onClick={() => { onSetDisabled(id, row.enabled !== false) }}
+                >
+                  <span className={css.thumb} />
+                </button>
+              </>
+            )}
+        </div>
+      </div>
+    </li>
+  )
+}
+
+/** Render one preset's capabilities: its rows, the add menu, and the last action's outcome. */
+export function PresetPluginsSection(props: PresetPluginsSectionProps): ReactNode {
+  const { t, presetId, presetName, ensure } = props
+  const state = props.usePluginManager(snapshot => snapshot)
+  const [addMenu, setAddMenu] = useState(false)
+  useEffect(() => { ensure() }, [ensure])
+
+  const target = { kind: 'preset', preset: presetId } as const
+  const preset = state.presets.find(candidate => candidate.id === presetId)
+  const loaded = state.status === 'ready' || state.status === 'error'
+  return (
+    <section className={css.group} data-plugin-scope="preset" data-preset-id={presetId}>
+      <div className={css.groupTitleRow}>
+        <h3 className={css.groupTitle}>{t('capabilitiesTitle')}</h3>
+        {preset === undefined || preset.broken !== undefined
+          ? null
+          : (
+            <Menu
+              open={addMenu}
+              onClose={() => { setAddMenu(false) }}
+              items={addChoices(state.packages, preset, t)}
+              onSelect={(id) => {
+                setAddMenu(false)
+                const choice = JSON.parse(id) as AddChoice
+                props.addRow(choice.packageName, choice.declaredName, target)
+              }}
+              align="end"
+              portal
+              anchor={(
+                <button
+                  type="button"
+                  className={css.addButton}
+                  aria-haspopup="menu"
+                  aria-expanded={addMenu}
+                  aria-label={t('capabilitiesAddLabel', { name: presetName })}
+                  disabled={state.busy.length > 0}
+                  onClick={() => { setAddMenu(current => !current) }}
+                >
+                  {t('capabilitiesAdd')}
+                </button>
+              )}
+            />
+          )}
+      </div>
+      {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
+      {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
+      {state.status === 'error' ? <p className={css.reason} role="alert">{t('error')}</p> : null}
+      {state.notice === null
+        ? null
+        : (
+          <p className={css.notice} data-kind={state.notice.kind} role={state.notice.kind === 'failed' ? 'alert' : 'status'}>
+            <span>{noticeText(state.notice, t)}</span>
+            <button type="button" className={css.linkButton} onClick={props.dismissNotice}>{t('dismiss')}</button>
+          </p>
+        )}
+      {!loaded || preset !== undefined ? null : <p className={css.empty}>{t('presetMissing')}</p>}
+      {preset?.broken === undefined ? null : <p className={css.reason} role="alert">{preset.broken}</p>}
+      {preset === undefined || preset.broken !== undefined
+        ? null
+        : preset.rows.length === 0
+          ? <p className={css.empty}>{t('presetRowsEmpty')}</p>
+          : (
+            <ul className={css.cards}>
+              {preset.rows.map((row, index) => (
+                <PresetCard
+                  key={`${row.entryId ?? row.moduleName}:${String(index)}`}
+                  preset={preset}
+                  row={row}
+                  packages={state.packages}
+                  t={t}
+                  busy={row.entryId !== null && state.busy.includes(rowKey(target, rowIdOf(row.entryId) as string))}
+                  onSetDisabled={(rowId, disabled) => { props.setRowDisabled(target, rowId, disabled) }}
+                  onRemove={(rowId) => { props.removeRow(target, rowId) }}
+                />
+              ))}
+            </ul>
+          )}
+    </section>
+  )
+}

+ 22 - 8
packages/client/ui-settings-plugin-manager/src/client/index.ts

@@ -1,8 +1,9 @@
 /**
  * Plugin manager, browser half: the **Manage plugins** tab of the Plugins
- * settings section. It installs, enables, disables, retries, and uninstalls
- * the packages of the Host's profile through the `plugins` Remote, and
- * composes rows into the global user layer or one agent preset's.
+ * settings section, and the capabilities section of every agent preset's
+ * detail page. The tab installs, enables, disables, retries, and uninstalls
+ * the packages of the Host's profile through the `plugins` Remote; the
+ * section composes rows into one preset's user layer.
  */
 
 import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -17,16 +18,19 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: the forwarded events' own declaration (`$on`'s key face resolves
 // through the owning package's client-safe types subpath).
 import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
-// Type-only: pulls the 'settings.agentPreset' LocaleNamespaceMap merge, whose
-// dictionaries the shipped-preset name resolution below reads.
+// Type-only: the 'settings.agentPreset' LocaleNamespaceMap merge the
+// shipped-preset name resolution reads, and the `settings.agentPreset.detail`
+// slot the capabilities section registers into.
 import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
 // Inline-safe shared fold: shipped ids map to dictionary keys in one home.
 import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
 import { PluginManagerSettingsTab } from './PluginManagerSettingsTab.tsx'
+import { PresetPluginsSection } from './PresetPluginsSection.tsx'
 import { PluginManagerController } from './manager-store.ts'
 import { en, zh, type PluginManagerLocaleKey } from './locales.ts'
 
 export type { PluginManagerSettingsTabProps } from './PluginManagerSettingsTab.tsx'
+export type { PresetPluginsSectionProps } from './PresetPluginsSection.tsx'
 export type {
   ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PluginManagerState, PresetGroup, PresetRow,
 } from './manager-store.ts'
@@ -46,8 +50,9 @@ export const NS = 'settings.pluginManager'
 export const inject = ['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory']
 
 /**
- * Contribute the manager tab to the Plugins settings section, and keep it
- * current on the Host's change events.
+ * Contribute the manager tab to the Plugins settings section and the
+ * capabilities section to every preset's detail page, and keep both current
+ * on the Host's change events.
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
@@ -71,12 +76,21 @@ export function apply(ctx: ClientContext): void {
     return () => { for (const dispose of disposers) dispose() }
   }, 'ui-settings-plugin-manager: host invalidations')
 
+  // Before the configuration tab: what is installed comes before how it is configured.
   ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
     name: 'settings.plugins.tab',
     id: 'manage',
-    order: 5,
+    order: -10,
     label: () => t('tab'),
     locale: NS,
     inject: () => controller.inject(),
   }, PluginManagerSettingsTab))
+
+  ctx.slots.inject('settings.agentPreset.detail', () => ctx.slots.register({
+    name: 'settings.agentPreset.detail',
+    id: 'plugins',
+    order: 0,
+    locale: NS,
+    inject: () => controller.inject(),
+  }, PresetPluginsSection))
 }

+ 16 - 18
packages/client/ui-settings-plugin-manager/src/client/locales.ts

@@ -22,13 +22,12 @@ export const zh = {
   dismiss: '知道了',
   installedTitle: '已安装',
   countUnit: '个',
-  presetTitle: '会话插件',
-  presetSubtitle: '这个预设的会话会用到的插件',
-  switcherLabel: '选择要管理的 Agent 预设',
-  presetOptionDefault: '{name}(默认)',
-  presetOptionBroken: '{name}(加载失败)',
-  presetRowsEmpty: '该预设没有组合任何插件。',
-  presetNoRoster: '本部署没有 Agent 预设。',
+  capabilitiesTitle: '能力',
+  capabilitiesAdd: '添加',
+  capabilitiesAddLabel: '给 {name} 添加能力',
+  capabilitiesAddEmpty: '没有可添加的插件',
+  presetRowsEmpty: '这个预设还没有任何能力。',
+  presetMissing: '这个预设已不存在。',
   builtinTag: '内置',
   localTag: '本地',
   statusRestart: '需重启',
@@ -63,8 +62,8 @@ export const zh = {
   rowStateFailed: '异常',
   rowToggle: '启用 {name}',
   rowLockedByComposition: '预设关闭了这个插件,不能在这里打开。',
-  rowRemove: '移出',
-  rowRemoveLabel: '从这个预设移出 {name}',
+  rowRemove: '删除',
+  rowRemoveLabel: '从这个预设删除 {name}',
   rowNoId: '这一行没有声明 id,无法在这里启停。',
   installTitle: '添加插件',
   installDescription: '输入插件的包名、本地路径或 Git 地址。',
@@ -198,13 +197,12 @@ export const en = {
   dismiss: 'Got it',
   installedTitle: 'Installed',
   countUnit: 'packages',
-  presetTitle: 'Session plugins',
-  presetSubtitle: 'What sessions of this preset use',
-  switcherLabel: 'Choose the agent preset to manage',
-  presetOptionDefault: '{name} (default)',
-  presetOptionBroken: '{name} (failed to load)',
-  presetRowsEmpty: 'This preset composes no plugins.',
-  presetNoRoster: 'This deployment has no agent presets.',
+  capabilitiesTitle: 'Capabilities',
+  capabilitiesAdd: 'Add',
+  capabilitiesAddLabel: 'Add a capability to {name}',
+  capabilitiesAddEmpty: 'No plugin left to add',
+  presetRowsEmpty: 'This preset has no capabilities yet.',
+  presetMissing: 'This preset no longer exists.',
   builtinTag: 'Built-in',
   localTag: 'Local',
   statusRestart: 'Restart needed',
@@ -239,8 +237,8 @@ export const en = {
   rowStateFailed: 'Problem',
   rowToggle: 'Enable {name}',
   rowLockedByComposition: 'The preset switched this plugin off; it cannot be switched on here.',
-  rowRemove: 'Remove',
-  rowRemoveLabel: 'Remove {name} from this preset',
+  rowRemove: 'Delete',
+  rowRemoveLabel: 'Delete {name} from this preset',
   rowNoId: 'This row declares no id, so it cannot be switched here.',
   installTitle: 'Add plugin',
   installDescription: 'Enter the plugin\'s package name, a local path, or a Git URL.',

+ 5 - 8
packages/client/ui-settings-plugin-manager/src/client/manager-store.ts

@@ -1,7 +1,8 @@
 /**
- * The plugin manager tab's state: the Host's package views and preset
- * compositions, the action in flight, the install run, and the confirmation
- * a destructive action waits on. Every fact comes from the Host — the store
+ * The plugin manager's state, shared by the Manage plugins tab and the
+ * capabilities section of a preset's detail page: the Host's package views
+ * and preset compositions, the action in flight, the install run, and the
+ * confirmation a destructive action waits on. Every fact comes from the Host — the store
  * re-reads after each action and after every `plugins/changed` event, so a
  * change made on another surface shows here without a manual refresh.
  */
@@ -79,8 +80,6 @@ export interface PluginManagerState {
   readonly presets: readonly PresetGroup[]
   /** Module names of the rows the host tree carries, for the **Add to…** menu's "added" marks. */
   readonly globalModules: readonly string[]
-  /** The preset whose composition the session group shows; null picks the default. */
-  readonly selectedPreset: string | null
   /** Package names and row keys with an action crossing the wire. */
   readonly busy: readonly string[]
   readonly notice: ManagerNotice | null
@@ -115,7 +114,6 @@ export interface PluginManagerFace {
   addRow: (packageName: string, declaredName: string, target: PluginRowTarget) => void
   removeRow: (target: PluginRowTarget, rowId: string) => void
   setRowDisabled: (target: PluginRowTarget, rowId: string, disabled: boolean) => void
-  selectPreset: (id: string) => void
   dismissNotice: () => void
   /** Display name for one preset, resolved through the agent-preset dictionaries. */
   presetName: (preset: PresetGroup) => string
@@ -174,7 +172,7 @@ export class PluginManagerController {
     private readonly presetName: (preset: PresetGroup) => string,
   ) {
     this.store = createSnapshotStore<PluginManagerState>({
-      status: 'idle', packages: [], presets: [], globalModules: [], selectedPreset: null, busy: [], notice: null,
+      status: 'idle', packages: [], presets: [], globalModules: [], busy: [], notice: null,
       install: IDLE_INSTALL, confirm: null,
     })
   }
@@ -235,7 +233,6 @@ export class PluginManagerController {
           this.answer(await this.ctx.remote.plugins.setRowDisabled(target, rowId, disabled))
         })
       },
-      selectPreset: (id) => { this.patch({ selectedPreset: id }) },
       dismissNotice: () => { this.patch({ notice: null }) },
       presetName: this.presetName,
     }

+ 145 - 0
packages/client/ui-settings-plugin-manager/src/client/presentation.ts

@@ -0,0 +1,145 @@
+/**
+ * What the manager's two surfaces say about a package, a preset row, or a
+ * failure: the dictionary lookup for harness modules, the package join for
+ * installed ones, and the notice copy — shared by the Manage plugins tab and
+ * the capabilities section it contributes to a preset's detail page.
+ */
+
+import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { zh, type PluginManagerLocaleKey } from './locales.ts'
+import type { ManagerNotice, PresetRow } from './manager-store.ts'
+
+/** The translate seat of the manager's dictionary. */
+export type Translate = PropsLocale<'settings.pluginManager'>['t']
+
+/** The scope every harness module is published under. */
+const FIRST_PARTY_SCOPE = '@deepseek-ai/'
+
+/**
+ * Compact a package name to what a person calls it.
+ * @param name - the package name.
+ * @returns the unscoped name without the harness prefixes.
+ */
+export function shortName(name: string): string {
+  const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
+  return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
+}
+
+/** The dictionary key for one slug, when the dictionary carries it. */
+function copyKey(kind: 'name' | 'desc', slug: string): PluginManagerLocaleKey | undefined {
+  const key = `${kind}.${slug}`
+  return key in zh ? key as PluginManagerLocaleKey : undefined
+}
+
+/**
+ * Display copy of one harness module: by the composition row id first (four
+ * subagent rows share one module), then by the unscoped module name without
+ * its `dsh-` prefix. Every `name.<slug>` key has its `desc.<slug>` partner.
+ * @param t - the manager's translate seat.
+ * @param moduleName - the module specifier the row names.
+ * @param rowId - the row id the composition declares, when known.
+ * @returns the copy, or undefined for a module outside the harness scope or
+ * one the dictionary does not name.
+ */
+export function harnessCopy(t: Translate, moduleName: string, rowId: string | null): { title: string; description: string } | undefined {
+  if (!moduleName.startsWith(FIRST_PARTY_SCOPE)) return undefined
+  const slugs = [...rowId === null ? [] : [rowId], moduleName.slice(FIRST_PARTY_SCOPE.length).replace(/^dsh-/, '')]
+  for (const slug of slugs) {
+    const name = copyKey('name', slug)
+    if (name === undefined) continue
+    return { title: t(name), description: t(`desc.${slug}` as PluginManagerLocaleKey) }
+  }
+  return undefined
+}
+
+/**
+ * The installed package a module belongs to: the package itself or one of its subpaths.
+ * @param moduleName - the module specifier.
+ * @param packages - the profile's packages.
+ * @returns the owning package view, or undefined when no installed package owns it.
+ */
+export function packageOf(moduleName: string, packages: readonly PluginPackageView[]): PluginPackageView | undefined {
+  return packages.find(pkg => moduleName === pkg.name || moduleName.startsWith(`${pkg.name}/`))
+}
+
+/**
+ * The row id a tree entry id ends in: what a user layer addresses.
+ * @param entryId - the inventory's entry id, tree-wide under a live mount.
+ * @returns the declared row id, or null for a row that declares none.
+ */
+export function rowIdOf(entryId: string | null): string | null {
+  return entryId === null ? null : entryId.slice(entryId.lastIndexOf(':') + 1)
+}
+
+/**
+ * What a person calls one tree entry: its harness name, else its row id.
+ * @param t - the manager's translate seat.
+ * @param entryId - the tree entry id.
+ * @returns the label.
+ */
+export function rowLabel(t: Translate, entryId: string): string {
+  const id = rowIdOf(entryId) as string
+  const key = copyKey('name', id)
+  return key === undefined ? id : t(key)
+}
+
+/**
+ * What one preset row shows: a title, a one-liner, and whether the person installed it.
+ * @param row - the inventory row.
+ * @param packages - the profile's packages, which name the installed ones.
+ * @param t - the manager's translate seat.
+ * @returns the copy.
+ */
+export function presetRowCopy(
+  row: PresetRow, packages: readonly PluginPackageView[], t: Translate,
+): { title: string; description?: string; local: boolean } {
+  const pkg = packageOf(row.moduleName, packages)
+  if (pkg !== undefined) {
+    const addable = pkg.addable.find(entry => entry.moduleName === row.moduleName)
+    return {
+      title: addable?.title ?? pkg.title ?? shortName(pkg.name),
+      ...pkg.description === undefined ? {} : { description: pkg.description },
+      local: pkg.trust === 'external',
+    }
+  }
+  const harness = harnessCopy(t, row.moduleName, rowIdOf(row.entryId))
+  if (harness !== undefined) return { ...harness, local: false }
+  return { title: shortName(row.moduleName), description: row.moduleName, local: !row.moduleName.startsWith(FIRST_PARTY_SCOPE) }
+}
+
+/**
+ * The copy for a refusal every mutation can meet: the manager is busy, or a session is running.
+ * @param failure - the Host's code and reason.
+ * @param t - the manager's translate seat.
+ * @returns the sentence.
+ */
+export function refusalText(failure: { readonly code: string; readonly reason: string }, t: Translate): string {
+  switch (failure.code) {
+    case 'plugins/busy': return t('busy', { reason: failure.reason })
+    case 'plugins/agents-running': return t('agentsRunning', { reason: failure.reason })
+    default: return failure.code === 'plugins/install-failed' ? t('installFailed') : t('actionFailed', { reason: failure.reason })
+  }
+}
+
+/**
+ * The sentence one notice shows.
+ * @param notice - the last action's outcome.
+ * @param t - the manager's translate seat.
+ * @returns the sentence.
+ */
+export function noticeText(notice: ManagerNotice, t: Translate): string {
+  switch (notice.kind) {
+    case 'restart': return t('restartNotice')
+    case 'done': return t('doneNotice')
+    case 'failed': {
+      switch (notice.code) {
+        case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason })
+        case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
+        case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
+        case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
+        default: return refusalText(notice, t)
+      }
+    }
+  }
+}

+ 14 - 2
packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -8,6 +8,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject, NS } from '../src/client/index.ts'
 import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
+import { PresetPluginsSection } from '../src/client/PresetPluginsSection.tsx'
 import type { PluginManagerFace } from '../src/client/manager-store.ts'
 import { apply as hostApply } from '../src/index.ts'
 
@@ -37,7 +38,10 @@ async function bench() {
 function declare(slots: SlotRegistry): () => void {
   return slots.register({
     name: 'root',
-    children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
+    children: {
+      'settings.plugins.tab': { kind: 'list', scope: 'root' },
+      'settings.agentPreset.detail': { kind: 'list', scope: 'root' },
+    },
   } as never, () => null)
 }
 
@@ -58,12 +62,19 @@ describe('ui-settings-plugin-manager browser plugin', () => {
 
     const entry = b.slots.entries('settings.plugins.tab')[0]!
     expect(entry.component).toBe(PluginManagerSettingsTab)
-    expect(entry.options).toMatchObject({ id: 'manage', order: 5 })
+    expect(entry.options).toMatchObject({ id: 'manage', order: -10 })
     expect(entry.locale).toBe(NS)
     expect(resolveSlotLabel(entry.options.label)).toBe('插件管理')
+    // The same store feeds the capabilities section of every preset's detail page.
+    const section = b.slots.entries('settings.agentPreset.detail')[0]!
+    expect(section.component).toBe(PresetPluginsSection)
+    expect(section.options).toMatchObject({ id: 'plugins', order: 0 })
+    expect(section.locale).toBe(NS)
+    const sectionFace = (section.inject as unknown as () => PluginManagerFace)()
     expect(b.list).not.toHaveBeenCalled()
 
     const face = (entry.inject as unknown as () => PluginManagerFace)()
+    expect(sectionFace.hooks.pluginManager).toBe(face.hooks.pluginManager)
     // A Host change before the first render is not a reason to read.
     b.remote.emit('plugins/changed', [{ reason: 'install' }])
     b.ctx.emit('connection/reset')
@@ -91,6 +102,7 @@ describe('ui-settings-plugin-manager browser plugin', () => {
 
     await fiber.dispose()
     expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+    expect(b.slots.entries('settings.agentPreset.detail')).toHaveLength(0)
     b.remote.emit('plugins/changed', [{ reason: 'install' }])
     await Promise.resolve()
     expect(b.list).toHaveBeenCalledTimes(3)

+ 1 - 77
packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx

@@ -6,7 +6,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
 import type { PluginManagerSettingsTabProps } from '../src/client/PluginManagerSettingsTab.tsx'
-import { rowKey, type InstallState, type PluginManagerState, type PresetGroup } from '../src/client/manager-store.ts'
+import type { InstallState, PluginManagerState, PresetGroup } from '../src/client/manager-store.ts'
 import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
 
 afterEach(cleanup)
@@ -49,15 +49,12 @@ const READY: PluginManagerState = {
   packages: [],
   presets: [],
   globalModules: [],
-  selectedPreset: null,
   busy: [],
   notice: null,
   install: IDLE_INSTALL,
   confirm: null,
 }
 
-const STANDARD_TARGET = { kind: 'preset', preset: 'standard' } as const
-
 function renderTab(state: Partial<PluginManagerState> = {}) {
   const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
   const actions = {
@@ -76,7 +73,6 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     addRow: vi.fn(),
     removeRow: vi.fn(),
     setRowDisabled: vi.fn(),
-    selectPreset: vi.fn(),
     dismissNotice: vi.fn(),
   }
   const props = {
@@ -117,7 +113,6 @@ describe('PluginManagerSettingsTab', () => {
     fireEvent.click(screen.getByRole('button', { name: en.retry }))
     expect(actions.refresh).toHaveBeenCalledTimes(1)
     expect(screen.getByText(en.empty)).toBeTruthy()
-    expect(screen.getByText(en.presetNoRoster)).toBeTruthy()
 
     set({ status: 'ready' })
     fireEvent.click(screen.getByRole('button', { name: en.refresh }))
@@ -281,77 +276,6 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.queryByText('./c')).toBeNull()
   })
 
-  it('renders the selected preset composition as cards with harness names, local marks, switches, and removal', () => {
-    const rows: PresetGroup['rows'] = [
-      { entryId: 'include:agent-presets:persona', moduleName: '@deepseek-ai/dsh-persona', enabled: true, fiberPhase: 'active', source: 'preset' },
-      { entryId: 'include:agent-presets:tool-subagent-fork', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null, source: 'preset' },
-      { entryId: 'nameless', moduleName: '@deepseek-ai/dsh-unknown-thing', enabled: true, fiberPhase: null, source: 'preset' },
-      { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' },
-      { entryId: 'gated', moduleName: '@fixture/gated', enabled: false, fiberPhase: null, source: 'preset', disabledBy: 'composition' },
-      { entryId: 'pwsh', moduleName: '@deepseek-ai/dsh-tool-pwsh', enabled: 'conditional', fiberPhase: null, source: 'preset' },
-      { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed', source: 'preset' },
-      { entryId: null, moduleName: '@deepseek-ai/dsh-tool-todo', enabled: true, fiberPhase: null, source: 'preset' },
-      { entryId: 'foo', moduleName: 'dsh-tool-foo', enabled: true, fiberPhase: null, source: 'user' },
-      { entryId: 'sub', moduleName: 'multi/a', enabled: true, fiberPhase: null, source: 'user' },
-    ]
-    const { actions, set } = renderTab({
-      packages: [
-        pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain', title: 'Foo tool', description: 'A foo.' }),
-        pkg({ name: '@fixture/extra', kind: 'plugin', status: 'plain' }),
-        pkg({ name: 'multi', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true }] }),
-      ],
-      presets: [preset({ rows }), preset({ id: 'research', trust: 'user', name: 'Research', isDefault: false })],
-    })
-    const switcher = screen.getByRole('button', { name: en.switcherLabel })
-    expect(switcher.textContent).toBe('标准 (default)')
-    // Harness modules read by their dictionary names: the row id first, then the module.
-    expect(screen.getByText(en['name.persona'])).toBeTruthy()
-    expect(screen.getByText(en['desc.persona'])).toBeTruthy()
-    expect(screen.getByText(en['name.tool-subagent-fork'])).toBeTruthy()
-    expect(screen.getByText(en['name.tool-todo'])).toBeTruthy()
-    expect(screen.getByText('unknown-thing')).toBeTruthy()
-    expect(screen.getByText('@deepseek-ai/dsh-unknown-thing')).toBeTruthy()
-    // Installed packages read by their manifest title, an addable module by its own.
-    expect(screen.getAllByText('Foo tool')).toHaveLength(2)
-    expect(screen.getAllByText('A foo.')).toHaveLength(2)
-    expect(screen.getByText('A tool')).toBeTruthy()
-    // Only the local mark and the problem tag; off and conditional rows show through their switches.
-    expect(screen.getAllByText(en.localTag)).toHaveLength(5)
-    expect(screen.getByText(en.rowStateFailed)).toBeTruthy()
-    expect(document.querySelectorAll('[data-preset-row] [data-kind]')).toHaveLength(6)
-    expect(screen.getByText(en.rowNoId)).toBeTruthy()
-    expect(screen.queryByRole('switch', { name: `Enable ${en['name.tool-todo']}` })).toBeNull()
-    expect(screen.getByRole('switch', { name: `Enable ${en['name.tool-pwsh']}` }).getAttribute('aria-checked')).toBe('true')
-
-    fireEvent.click(screen.getByRole('switch', { name: `Enable ${en['name.persona']}` }))
-    expect(actions.setRowDisabled).toHaveBeenCalledWith(STANDARD_TARGET, 'persona', true)
-    const extra = screen.getByRole('switch', { name: 'Enable extra' })
-    expect(extra.getAttribute('aria-checked')).toBe('false')
-    fireEvent.click(extra)
-    expect(actions.setRowDisabled).toHaveBeenLastCalledWith(STANDARD_TARGET, 'extra', false)
-    const gated = screen.getByRole('switch', { name: 'Enable gated' }) as HTMLButtonElement
-    expect(gated.disabled).toBe(true)
-    expect(gated.title).toBe(en.rowLockedByComposition)
-    fireEvent.click(screen.getByRole('button', { name: 'Remove extra from this preset' }))
-    expect(actions.removeRow).toHaveBeenCalledWith(STANDARD_TARGET, 'extra')
-    expect(screen.queryByRole('button', { name: `Remove ${en['name.persona']} from this preset` })).toBeNull()
-
-    set({ busy: [rowKey(STANDARD_TARGET, 'persona')] })
-    expect(screen.getByRole('switch', { name: `Enable ${en['name.persona']}` })).toHaveProperty('disabled', true)
-
-    fireEvent.click(switcher)
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('menuitem')).toBeNull()
-    fireEvent.click(switcher)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Research' }))
-    expect(actions.selectPreset).toHaveBeenCalledWith('research')
-    set({ selectedPreset: 'research' })
-    expect(screen.getByText(en.presetRowsEmpty)).toBeTruthy()
-    set({ selectedPreset: 'broken', presets: [preset({ id: 'broken', trust: 'user', name: 'Broken', isDefault: false, broken: 'bad yaml' })] })
-    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('Broken (failed to load)')
-    expect(screen.getByRole('alert').textContent).toBe('bad yaml')
-  })
-
   it('drives the install dialog through its phases and words each outcome', () => {
     const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
     expect(screen.getByText(en.installExample)).toBeTruthy()

+ 0 - 2
packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts

@@ -238,8 +238,6 @@ describe('PluginManagerController', () => {
     face.removeRow(target, 'extra')
     await vi.waitFor(() => { expect(plugins.removeRow).toHaveBeenCalledWith(target, 'extra') })
     await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    face.selectPreset('research')
-    expect(state().selectedPreset).toBe('research')
   })
 
   it('reports a row conflict with the row id and a thrown transport failure generically', async () => {

+ 210 - 0
packages/client/ui-settings-plugin-manager/tests/preset-section.client.spec.tsx

@@ -0,0 +1,210 @@
+// @vitest-environment jsdom
+/**
+ * The capabilities section of a preset's detail page: rows named through
+ * the dictionary or the package manifest, one switch each, delete for the
+ * rows the person added, an add menu over what the preset does not carry
+ * yet, and the store's read states and notices.
+ */
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { PluginPackageView } 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 { PresetPluginsSection } from '../src/client/PresetPluginsSection.tsx'
+import type { PresetPluginsSectionProps } from '../src/client/PresetPluginsSection.tsx'
+import { rowKey, type PluginManagerState, type PresetGroup } from '../src/client/manager-store.ts'
+import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
+  Object.entries(params ?? {}).reduce(
+    (text, [name, value]) => text.replaceAll(`{${name}}`, value),
+    en[key],
+  )) as PresetPluginsSectionProps['t']
+
+function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
+  return {
+    name: 'dsh-tool-foo',
+    version: '0.1.0',
+    kind: 'plugin',
+    trust: 'external',
+    stage: 'runtime',
+    installed: true,
+    enabled: false,
+    status: 'plain',
+    cordisSameCopy: true,
+    rows: [],
+    overrides: [],
+    addable: [{ moduleName: 'dsh-tool-foo', declaredName: '.', ok: true }],
+    liveReload: true,
+    ...overrides,
+  }
+}
+
+function preset(overrides: Partial<PresetGroup> = {}): PresetGroup {
+  return { id: 'standard', trust: 'system', name: '标准', isDefault: true, rows: [], ...overrides }
+}
+
+const READY: PluginManagerState = {
+  status: 'ready',
+  packages: [],
+  presets: [],
+  globalModules: [],
+  busy: [],
+  notice: null,
+  install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null },
+  confirm: null,
+}
+
+const TARGET = { kind: 'preset', preset: 'standard' } as const
+
+function renderSection(state: Partial<PluginManagerState> = {}, presetId = 'standard') {
+  const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
+  const actions = {
+    ensure: vi.fn(),
+    addRow: vi.fn(),
+    removeRow: vi.fn(),
+    setRowDisabled: vi.fn(),
+    dismissNotice: vi.fn(),
+  }
+  const props = {
+    t,
+    ...actions,
+    presetId,
+    presetName: '标准',
+    usePluginManager: bindSnapshotSelector(store),
+  } as unknown as PresetPluginsSectionProps
+  render(<PresetPluginsSection {...props} />)
+  return { actions, set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } }
+}
+
+describe('PresetPluginsSection', () => {
+  it('asks the store once mounted and renders the read states, a missing preset, and a broken one', () => {
+    const { actions, set } = renderSection({ status: 'loading' })
+    expect(actions.ensure).toHaveBeenCalledTimes(1)
+    expect(screen.getByText(en.loading)).toBeTruthy()
+    expect(screen.queryByRole('button', { name: 'Add a capability to 标准' })).toBeNull()
+
+    set({ status: 'unavailable' })
+    expect(screen.getByRole('status').textContent).toBe(en.unavailable)
+
+    set({ status: 'error' })
+    expect(screen.getByRole('alert').textContent).toBe(en.error)
+    expect(screen.getByText(en.presetMissing)).toBeTruthy()
+
+    set({ status: 'ready', presets: [preset({ broken: 'bad yaml' })] })
+    expect(screen.getByRole('alert').textContent).toBe('bad yaml')
+    expect(screen.queryByText(en.presetMissing)).toBeNull()
+    expect(screen.queryByRole('button', { name: 'Add a capability to 标准' })).toBeNull()
+
+    set({ presets: [preset()] })
+    expect(screen.getByText(en.presetRowsEmpty)).toBeTruthy()
+  })
+
+  it('renders the rows as cards with harness names, local marks, switches, and delete for the rows the person added', () => {
+    const rows: PresetGroup['rows'] = [
+      { entryId: 'include:agent-presets:persona', moduleName: '@deepseek-ai/dsh-persona', enabled: true, fiberPhase: 'active', source: 'preset' },
+      { entryId: 'include:agent-presets:tool-subagent-fork', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null, source: 'preset' },
+      { entryId: 'nameless', moduleName: '@deepseek-ai/dsh-unknown-thing', enabled: true, fiberPhase: null, source: 'preset' },
+      { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' },
+      { entryId: 'gated', moduleName: '@fixture/gated', enabled: false, fiberPhase: null, source: 'preset', disabledBy: 'composition' },
+      { entryId: 'pwsh', moduleName: '@deepseek-ai/dsh-tool-pwsh', enabled: 'conditional', fiberPhase: null, source: 'preset' },
+      { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed', source: 'preset' },
+      { entryId: null, moduleName: '@deepseek-ai/dsh-tool-todo', enabled: true, fiberPhase: null, source: 'preset' },
+      { entryId: 'foo', moduleName: 'dsh-tool-foo', enabled: true, fiberPhase: null, source: 'user' },
+      { entryId: 'sub', moduleName: 'multi/a', enabled: true, fiberPhase: null, source: 'user' },
+    ]
+    const { actions, set } = renderSection({
+      packages: [
+        pkg({ title: 'Foo tool', description: 'A foo.' }),
+        pkg({ name: '@fixture/extra', addable: [] }),
+        pkg({ name: 'multi', addable: [{ moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true }] }),
+      ],
+      presets: [preset({ rows })],
+    })
+    // Harness modules read by their dictionary names: the row id first, then the module.
+    expect(screen.getByText(en['name.persona'])).toBeTruthy()
+    expect(screen.getByText(en['desc.persona'])).toBeTruthy()
+    expect(screen.getByText(en['name.tool-subagent-fork'])).toBeTruthy()
+    expect(screen.getByText(en['name.tool-todo'])).toBeTruthy()
+    expect(screen.getByText('unknown-thing')).toBeTruthy()
+    expect(screen.getByText('@deepseek-ai/dsh-unknown-thing')).toBeTruthy()
+    // Installed packages read by their manifest title, an addable module by its own.
+    expect(screen.getByText('Foo tool')).toBeTruthy()
+    expect(screen.getByText('A foo.')).toBeTruthy()
+    expect(screen.getByText('A tool')).toBeTruthy()
+    expect(screen.getAllByText(en.localTag)).toHaveLength(5)
+    expect(screen.getByText(en.rowStateFailed)).toBeTruthy()
+    expect(document.querySelectorAll('[data-preset-row] [data-kind]')).toHaveLength(6)
+    expect(screen.getByText(en.rowNoId)).toBeTruthy()
+    expect(screen.queryByRole('switch', { name: `Enable ${en['name.tool-todo']}` })).toBeNull()
+    expect(screen.getByRole('switch', { name: `Enable ${en['name.tool-pwsh']}` }).getAttribute('aria-checked')).toBe('true')
+
+    fireEvent.click(screen.getByRole('switch', { name: `Enable ${en['name.persona']}` }))
+    expect(actions.setRowDisabled).toHaveBeenCalledWith(TARGET, 'persona', true)
+    const extra = screen.getByRole('switch', { name: 'Enable extra' })
+    expect(extra.getAttribute('aria-checked')).toBe('false')
+    fireEvent.click(extra)
+    expect(actions.setRowDisabled).toHaveBeenLastCalledWith(TARGET, 'extra', false)
+    const gated = screen.getByRole('switch', { name: 'Enable gated' }) as HTMLButtonElement
+    expect(gated.disabled).toBe(true)
+    expect(gated.title).toBe(en.rowLockedByComposition)
+    fireEvent.click(screen.getByRole('button', { name: 'Delete extra from this preset' }))
+    expect(actions.removeRow).toHaveBeenCalledWith(TARGET, 'extra')
+    expect(screen.queryByRole('button', { name: `Delete ${en['name.persona']} from this preset` })).toBeNull()
+
+    set({ busy: [rowKey(TARGET, 'persona')] })
+    expect(screen.getByRole('switch', { name: `Enable ${en['name.persona']}` })).toHaveProperty('disabled', true)
+  })
+
+  it('offers to add the installed modules the preset does not carry yet', () => {
+    const { actions, set } = renderSection({
+      packages: [
+        pkg({ title: 'Foo tool' }),
+        pkg({
+          name: 'multi',
+          title: 'Multi',
+          addable: [
+            { moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true },
+            { moduleName: 'multi/b', declaredName: './b', ok: true },
+            { moduleName: 'multi/c', declaredName: './c', ok: false, error: 'cannot import' },
+          ],
+        }),
+      ],
+      presets: [preset({ rows: [{ entryId: 'sub', moduleName: 'multi/a', enabled: true, fiberPhase: null, source: 'user' }] })],
+    })
+    const add = screen.getByRole('button', { name: 'Add a capability to 标准' })
+    fireEvent.click(add)
+    // The module the preset carries and the one that cannot import are left out.
+    expect(screen.getByRole('menuitem', { name: 'Foo tool' })).toBeTruthy()
+    expect(screen.getByRole('menuitem', { name: 'Multi · ./b' })).toBeTruthy()
+    expect(screen.queryByRole('menuitem', { name: 'A tool' })).toBeNull()
+    expect(screen.getAllByRole('menuitem')).toHaveLength(2)
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Multi · ./b' }))
+    expect(actions.addRow).toHaveBeenCalledWith('multi', './b', TARGET)
+    expect(screen.queryByRole('menuitem')).toBeNull()
+
+    // Nothing left to add reads as such; an action in flight keeps the menu shut.
+    set({ presets: [preset({ rows: [
+      { entryId: 'sub', moduleName: 'multi/a', enabled: true, fiberPhase: null, source: 'user' },
+      { entryId: 'b', moduleName: 'multi/b', enabled: true, fiberPhase: null, source: 'user' },
+      { entryId: 'foo', moduleName: 'dsh-tool-foo', enabled: true, fiberPhase: null, source: 'user' },
+    ] })] })
+    fireEvent.click(add)
+    expect(screen.getByRole('menuitem', { name: en.capabilitiesAddEmpty })).toBeTruthy()
+    fireEvent.keyDown(document, { key: 'Escape' })
+    set({ busy: ['multi'] })
+    expect(add).toHaveProperty('disabled', true)
+  })
+
+  it('shows the last action\'s outcome and dismisses it', () => {
+    const { actions, set } = renderSection({ presets: [preset()], notice: { kind: 'done', packageName: 'x' } })
+    expect(screen.getByRole('status').textContent).toContain(en.doneNotice)
+    set({ notice: { kind: 'failed', code: 'plugins/row-conflict', reason: 'r', rowId: 'row-1' } })
+    expect(screen.getByRole('alert').textContent).toContain(en.rowConflict.replace('{row}', 'row-1'))
+    fireEvent.click(screen.getByRole('button', { name: en.dismiss }))
+    expect(actions.dismissNotice).toHaveBeenCalledTimes(1)
+  })
+})

+ 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: d2c4983ce5ae350fd0a0391dbec45bc1c81ee8de
-README.zh.md: 4133e58fc2b611294b4a1b557241169c71159d65
+README.md: bff9626a1ef98ccecb95ede09105a11109a18ae3
+README.zh.md: 9d805b5cf35946c87f790458fccb41d8cc301289

+ 5 - 4
packages/client/ui-settings-plugins/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. One switch above the cards chooses the scope every card edits — the values shared by all agent presets, or one preset's own — and under a preset a field marks whether it inherits the shared value. Cards stage edits locally per scope and write only on save, with every write fenced by the namespace revision the form read.
+`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. The tab edits the values shared by all agent presets; the same cards form the **Settings** section of every preset's detail page in the Agent presets section, where they edit that preset's own values and a field marks whether it inherits the shared value. Cards stage edits locally per scope and write only on save, with every write fenced by the namespace revision the form read.
 
 ## Table of Contents
 
@@ -27,9 +27,9 @@ English | [中文](README.zh.md)
 
 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`), the DeepSeek search provider (`web-search-deepseek`), and the filesystem skill provider's extra roots (`skill-filesystem`).
 
-### Choosing the scope
+### A preset's own values
 
-The **Applies to** switch above the cards selects the settings scope every card edits: **All presets** is the global instance of each namespace, and each agent preset in the roster is that preset's named scope (`preset/<id>`). Under a preset, a field the preset does not override shows **Inherited** when the shared user layer carries it, a reset stages the inherited value rather than the composition default, and a card whose plugin the preset does not compose says so — its values are stored and take effect once a preset composes the plugin. Drafts belong to the scope they were typed under and survive a switch. A roster the Host refuses leaves the global instance editable and says the presets could not be listed.
+A preset's detail page — behind the gear on its card in the Agent presets section — carries a **Settings** section with the same cards, editing that preset's named scope (`preset/<id>`). There a field the preset does not override shows **Inherited** when the shared user layer carries it, a reset stages the inherited value rather than the composition default, and a card whose plugin no running session of the preset has loaded says so — its values are stored and apply to the sessions that load the plugin. Drafts belong to the scope they were typed under and survive leaving the page. The configuration tab always edits the shared values.
 
 ### What appears here
 
@@ -57,7 +57,7 @@ The section is one extension point and one dispatch rule: feature plugins own th
 
 ### The tab extension point
 
-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 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 preset settings section registers into the roster's `settings.agentPreset.detail` slot and declares `settings.agentPreset.plugin.item`, keyed the same way; a card registers under both slots to appear on both surfaces. One scope selection serves every card — the global instance while the configuration tab shows, the preset's scope while its detail page is open; the settings shell mounts one section at a time, so the two never compete.
 
 ### The write path
 
@@ -98,6 +98,7 @@ These limits define which plugins appear and how fresh the list is; they are cur
 - **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.
+- **An orphaned scope section has no surface** — a `scopes.<id>` section whose preset was deleted is edited nowhere: the detail page that would show it is gone, and the configuration tab edits only the shared values, so the section stays in the document until edited by hand.
 - **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.
 
 <a id="dev-note"></a>

+ 5 - 4
packages/client/ui-settings-plugins/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。卡片上方的一个开关选择每张卡片编辑的范围——所有 Agent 预设共用的值,或某个预设自己的值——在预设之下,字段会标注它是否继承共用值。卡片按范围暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。
+`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。标签页编辑所有 Agent 预设共用的值;同样的卡片构成 Agent 预设分区里每个预设详情页的**设置**段,在那里编辑该预设自己的值,字段会标注它是否继承共用值。卡片按范围暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。
 
 ## 目录
 
@@ -27,9 +27,9 @@ kind: "package-reference"
 
 打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)、DeepSeek 搜索提供方(`web-search-deepseek`)以及文件系统技能提供方的额外目录(`skill-filesystem`)。
 
-### 选择生效范围
+### 预设自己的值
 
-卡片上方的**生效范围**开关选择每张卡片编辑的 settings scope:**所有预设**是每个命名空间的全局实例,roster 中的每个 Agent 预设则是该预设的具名 scope(`preset/<id>`)。在某个预设之下,预设未覆盖而共用用户层携带的字段显示**继承**,重置暂存的是继承值而不是组合默认值,插件未被该预设组合的卡片会说明这一点——它的值会被保存,并在某个预设组合该插件后生效。草稿属于输入它时所在的范围,切换后仍然保留。宿主拒绝 roster 时全局实例仍可编辑,并提示无法列出预设
+预设的详情页——Agent 预设分区里卡片上的齿轮之后——带一个**设置**段,用同样的卡片编辑该预设的命名作用域(`preset/<id>`)。在那里,预设未覆盖的字段在共用用户层持有它时显示**继承**,重置暂存的是继承值而非组装默认值,而所属插件没有被该预设任何运行中会话加载的卡片会如此说明——其值照样存储,对加载该插件的会话生效。草稿属于输入时所在的作用域,离开页面后仍在。配置标签页始终编辑共用值
 
 ### 这里会出现什么
 
@@ -57,7 +57,7 @@ Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
 
 ### 标签页扩展点
 
-本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。标签页遵循贡献的 `order`;卡片遵循注册顺序。
+本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。标签页遵循贡献的 `order`;卡片遵循注册顺序。预设设置段注册进名单的 `settings.agentPreset.detail` 槽位并声明按同样方式键控的 `settings.agentPreset.plugin.item`;卡片在两个槽位下都注册才会出现在两个表面上。一个作用域选择服务所有卡片——配置标签页显示时是全局实例,预设详情页打开时是该预设的作用域;设置外壳一次只挂载一个分区,因此两者从不竞争。
 
 ### 写入路径
 
@@ -98,6 +98,7 @@ Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
 - **只有宿主平面的插件会出现**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
 - **卡片仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。
 - **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在标签页读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。
+- **孤儿作用域段没有界面**:预设已被删除的 `scopes.<id>` 段无处编辑——会显示它的详情页已不存在,而配置标签页只编辑共用值,因此该段一直留在文档里,直到手工编辑。
 - **shell 卡片跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段。
 
 <a id="dev-note"></a>

+ 0 - 1
packages/client/ui-settings-plugins/package.json

@@ -46,7 +46,6 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",

+ 8 - 65
packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx

@@ -4,91 +4,34 @@
  * 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. Above the cards, one switch chooses the scope every
- * card edits: the values shared by all agent presets, or one preset's own.
+ * which keys to dispatch. Every card edits the global instance of its
+ * namespace here; a preset's own values are edited on the preset's detail page.
  */
 
-import { Fragment, useEffect, useState } from 'react'
-import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+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 { PRESET_SCOPE_PREFIX, type PresetScopeRow, type ScopeSwitcherFace } from './scope-switcher.ts'
 import css from './PluginsSettingsSection.module.css'
 
-/** Menu id of the global instance; a scope id never collides with it, as every scope carries a `/`. */
-const GLOBAL_OPTION = 'global'
-
 /** Props the renderer binds for the configurable tab. */
 export type ConfigurablePluginsTabProps =
   PropsRuntime<'settings.plugins.tab'>
   & PropsLocale<'settings.plugins'>
   & PropsRenderSlots<'settings.plugin.item'>
-  & InjectFace<ConfigurablePluginsTabFace & ScopeSwitcherFace>
+  & InjectFace<ConfigurablePluginsTabFace>
 
 /**
- * Render the scope switch and the cards registered by plugins that expose
- * editable settings.
- * @param props - locale copy, slot rendering, the namespaces to dispatch, and the scope switch.
- * @returns the switch and card list, or the empty line once the Host has answered.
+ * Render the 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, ensureScopes, selectScope, presetName } = props
+  const { t, renderSlot } = props
   const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot)
-  const switcher = props.useScopeSwitcher(snapshot => snapshot)
-  const [open, setOpen] = useState(false)
-  useEffect(() => { ensureScopes() }, [ensureScopes])
-
-  const presetLabel = (preset: PresetScopeRow): string => {
-    const name = presetName(preset)
-    if (preset.broken !== undefined) return t('scopePresetBroken', { name })
-    if (preset.isDefault) return t('scopePresetDefault', { name })
-    return name
-  }
-  const items = [
-    { id: GLOBAL_OPTION, label: t('scopeGlobal') },
-    ...switcher.presets.map(preset => ({ id: `${PRESET_SCOPE_PREFIX}${preset.id}`, label: presetLabel(preset) })),
-    ...switcher.extraScopes.map(scope => ({ id: scope, label: scope })),
-  ]
-  const selectedId = switcher.scope ?? GLOBAL_OPTION
-  const selected = items.find(item => item.id === selectedId)
-  // A selected scope the options no longer list keeps its raw id visible.
-  const selectedLabel = selected?.label ?? selectedId
 
   return (
     <div className={css.configurable}>
-      <div className={css.scopeRow}>
-        <span className={css.scopeLabel}>{t('scopeLabel')}</span>
-        <Menu
-          open={open}
-          onClose={() => { setOpen(false) }}
-          items={items}
-          selectedId={selectedId}
-          onSelect={(id) => {
-            setOpen(false)
-            selectScope(id === GLOBAL_OPTION ? null : id)
-          }}
-          align="end"
-          portal
-          anchor={(
-            <button
-              type="button"
-              className={css.scopeSwitcher}
-              aria-haspopup="menu"
-              aria-expanded={open}
-              aria-label={t('scopeSwitcherLabel')}
-              data-settings-scope={switcher.scope ?? 'global'}
-              onClick={() => { setOpen(value => !value) }}
-            >
-              <span className={css.scopeSwitcherLabel}>{selectedLabel}</span>
-              <IconChevronDownOutline14 className={css.scopeChevron} aria-hidden="true" />
-            </button>
-          )}
-        />
-      </div>
-      <p className={css.scopeHint}>
-        {switcher.status === 'error' ? t('scopeRosterFailed') : t(switcher.scope === undefined ? 'scopeHintGlobal' : 'scopeHintPreset')}
-      </p>
       {namespaces.length > 0
         ? (
           <ul className={css.cards}>

+ 10 - 52
packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css

@@ -84,7 +84,7 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* Configurable tab: the scope switch above the card list. */
+/* Configurable tab: the card list. */
 
 .configurable {
   display: flex;
@@ -92,59 +92,17 @@
   gap: 10px;
 }
 
-.scopeRow {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-}
-
-.scopeLabel {
-  font-size: 13px;
-  font-weight: 500;
-  color: var(--dsw-alias-label-primary);
-}
-
-.scopeSwitcher {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  max-width: 320px;
-  height: 30px;
-  padding: 0 10px 0 12px;
-  border: 0.5px solid var(--dsw-alias-border-l4);
-  border-radius: 999px;
-  corner-shape: round;
-  background: var(--dsw-alias-bg-layer-3);
-  color: var(--dsw-alias-label-primary);
-  font: inherit;
-  font-size: 13px;
-  cursor: pointer;
-}
-
-.scopeSwitcher:hover {
-  border-color: var(--dsw-alias-label-dimmed);
-}
-
-.scopeSwitcher:focus-visible {
-  outline: 2px solid var(--dsw-alias-brand-primary);
-  outline-offset: 1px;
-}
-
-.scopeSwitcherLabel {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
+/* Preset detail page: the settings section over the same cards. */
 
-.scopeChevron {
-  flex: none;
-  color: var(--dsw-alias-label-tertiary);
+.presetSettings {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
 }
 
-.scopeHint {
+.presetSettingsTitle {
   margin: 0;
-  font-size: 12px;
-  line-height: 1.5;
-  color: var(--dsw-alias-label-tertiary);
+  font-size: 15px;
+  line-height: 22px;
+  font-weight: 600;
 }

+ 65 - 0
packages/client/ui-settings-plugins/src/client/PresetSettingsSection.tsx

@@ -0,0 +1,65 @@
+/**
+ * The settings section of one agent preset's detail page: the plugin cards
+ * registered into `settings.agentPreset.plugin.item`, editing the preset's own
+ * scope (`preset/<id>`). Contributed to the roster's `settings.agentPreset.detail`
+ * slot. While the section is mounted the card scope selection names the
+ * preset; unmounting returns every card to the global instance. The settings
+ * shell mounts one section at a time, so this page and the configuration tab
+ * never hold the selection together.
+ */
+
+import { Fragment, useLayoutEffect } from 'react'
+import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: the detail slot's declaration, owned by the roster.
+import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
+import type {} from './slot-contract.ts'
+import type { ConfigurablePluginsTabFace } from './tab-store.ts'
+import css from './PluginsSettingsSection.module.css'
+
+/** Scope id prefix the agent-preset roster names its standing scopes with. */
+export const PRESET_SCOPE_PREFIX = 'preset/'
+
+/** The registration-side face the section injects: the card directory plus the scope selection. */
+export interface PresetSettingsFace extends ConfigurablePluginsTabFace {
+  /** Select the scope every card edits; null selects the global instance. */
+  selectScope: (scope: string | null) => void
+}
+
+/** Props the renderer binds for the section. */
+export type PresetSettingsSectionProps =
+  PropsRuntime<'settings.agentPreset.detail'>
+  & PropsLocale<'settings.plugins'>
+  & PropsRenderSlots<'settings.agentPreset.plugin.item'>
+  & InjectFace<PresetSettingsFace>
+
+/**
+ * Render one preset's settings: the cards registered by plugins that expose
+ * editable settings, under the preset's scope.
+ * @param props - locale copy, slot rendering, the namespaces to dispatch, the preset, and the scope selection.
+ * @returns the section, with the empty line once the Host has answered.
+ */
+export function PresetSettingsSection(props: PresetSettingsSectionProps) {
+  const { t, renderSlot, presetId, selectScope } = props
+  const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot)
+  const scope = `${PRESET_SCOPE_PREFIX}${presetId}`
+  // Before paint, so the cards never show the global instance's values first.
+  useLayoutEffect(() => {
+    selectScope(scope)
+    return () => { selectScope(null) }
+  }, [scope, selectScope])
+
+  return (
+    <section className={css.presetSettings} data-settings-scope={scope}>
+      <h3 className={css.presetSettingsTitle}>{t('presetSettingsTitle')}</h3>
+      {namespaces.length > 0
+        ? (
+          <ul className={css.cards}>
+            {namespaces.map(ns => (
+              <Fragment key={ns}>{renderSlot('settings.agentPreset.plugin.item', {}, { entryKey: ns })}</Fragment>
+            ))}
+          </ul>
+        )
+        : loaded ? <p className={css.empty}>{t('empty')}</p> : null}
+    </section>
+  )
+}

+ 1 - 1
packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts

@@ -39,7 +39,7 @@ export class AgentLoopCardController {
   private readonly store: SnapshotStore<AgentLoopCardState>
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the `agent-loop` namespace under one scope.
    */
   constructor(selection: ScopeSelection, bindScope: BindScope<AgentLoopSettings>) {

+ 1 - 1
packages/client/ui-settings-plugins/src/client/bash-card-controller.ts

@@ -41,7 +41,7 @@ export class BashCardController {
   private readonly store: SnapshotStore<BashCardState>
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the `bash` namespace under one scope.
    */
   constructor(selection: ScopeSelection, bindScope: BindScope<BashSettings>) {

+ 59 - 73
packages/client/ui-settings-plugins/src/client/index.ts

@@ -1,12 +1,16 @@
 /**
  * Plugins settings surface, browser half — one section whose feature-owned
- * tabs include configurable Host plugin cards and read-only inventory.
+ * tabs include configurable Host plugin cards, and the settings section of
+ * every agent preset's detail page.
  *
  * 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.
+ * into it, editing the global instance of each namespace. The preset detail
+ * section declares `settings.agentPreset.plugin.item` and renders the cards
+ * registered there under the preset's own scope. The cards this package ships
+ * are the host-plane sections the deployment already exposes, registered under
+ * both slots; each binds its namespace through the client settings scope,
+ * which keeps them unaware of one another and of other surfaces.
  */
 
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -20,15 +24,14 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: the ctx.remote Context merge and the forwarded-event key face.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
-// Type-only: pulls the 'settings.agentPreset' LocaleNamespaceMap merge, whose
-// dictionaries the shipped-preset name resolution below reads.
+// Type-only: the `settings.agentPreset.detail` slot the preset settings
+// section registers into, declared by the roster.
 import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
-// Inline-safe shared fold: shipped ids map to dictionary keys in one home.
-import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
 import { AgentLoopCard } from './AgentLoopCard.tsx'
 import { BashCard } from './BashCard.tsx'
 import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
 import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
+import { PresetSettingsSection, type PresetSettingsFace } from './PresetSettingsSection.tsx'
 import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
 import { SkillFilesystemCard } from './SkillFilesystemCard.tsx'
 import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
@@ -37,7 +40,7 @@ import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-contro
 import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
 import { ConfigurablePluginsTabController } from './tab-store.ts'
 import { ScopeSelection } from './scoped-form.ts'
-import { ScopeSwitcherController, type PresetScopeRow } from './scope-switcher.ts'
+import type { CardSlot } from './slot-contract.ts'
 import { SKILL_FILESYSTEM_NS, SkillFilesystemCardController } from './skill-filesystem-card-controller.ts'
 import {
   SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController,
@@ -47,11 +50,11 @@ import { en, zh } from './locales.ts'
 
 export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx'
 export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
+export type { PresetSettingsFace, PresetSettingsSectionProps } from './PresetSettingsSection.tsx'
 export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts'
 export type { BindScope, ScopeSelectionState } from './scoped-form.ts'
-export type { PresetScopeRow, ScopeSwitcherFace, ScopeSwitcherState } from './scope-switcher.ts'
 export type { PluginCardProps } from './PluginCard.tsx'
-export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
+export type { CardSlot, SettingsPluginItemOwnerProps } from './slot-contract.ts'
 export type { FieldProps } from './fields.tsx'
 export type {
   CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
@@ -65,21 +68,22 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
 const NS = 'settings.plugins'
 
 /** Required services (cordis fiber inject). */
-export const inject = [
-  'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.credentials', 'remote.session', 'settingsScope',
-]
+export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope']
 
 /**
- * Mount the plugin configuration section and the cards this package ships.
+ * Mount the plugin configuration section, the settings section of every
+ * preset's detail page, and the cards this package ships under both.
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
   const t = ctx.locale.bind(NS)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
 
-  // One scope selection over every card: the global instance, or one
-  // preset's named scope. A card binds its namespace under a scope the first
-  // time that scope is selected.
+  // One scope selection over every card: the global instance while the
+  // configuration tab shows, one preset's scope while that preset's detail
+  // page is open. The settings shell mounts one section at a time, so the two
+  // surfaces never hold the selection together. A card binds its namespace
+  // under a scope the first time that scope is selected.
   const selection = new ScopeSelection()
   const bindScope = <T>(namespace: string) => (scope: string | undefined) =>
     ctx.settingsScope.bind<T>({ namespace, ...scope === undefined ? {} : { scope } })
@@ -90,19 +94,6 @@ export function apply(ctx: ClientContext): void {
     selection, bindScope(SUBAGENT_MODEL_SELECTION_NS), ctx)
   const skillFilesystem = new SkillFilesystemCardController(selection, bindScope(SKILL_FILESYSTEM_NS))
 
-  // A refused or failed roster is the switch's state to report; the Host's
-  // message belongs to the transport, not to a scope picker.
-  const roster = (): Promise<readonly PresetScopeRow[] | undefined> => ctx.remote.agentPresets.list()
-    .then(result => result.ok ? result.value.presets : undefined, () => undefined)
-  const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
-  const switcher = new ScopeSwitcherController(
-    selection, ctx.settingsScope.describe(), roster, preset => presetDisplayText(preset, agentPresetCopy).name)
-  ctx.effect(() => () => { switcher.dispose() }, 'ui-settings-plugins: scope switch')
-  ctx.effect(
-    () => ctx.on('connection/reset', () => { switcher.reset() }),
-    'ui-settings-plugins: scope roster generation',
-  )
-
   // The credential a card 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.
@@ -124,15 +115,18 @@ export function apply(ctx: ClientContext): void {
   )
   ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
 
-  // 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',
-  )
+  // One card directory per surface over the shared SettingsScope mirror, which
+  // updates after document commits and reconnects.
+  const describeFace = ctx.settingsScope.describe()
+  const directory = (slot: CardSlot): ConfigurablePluginsTabController => {
+    const controller = new ConfigurablePluginsTabController(describeFace, () => ctx.slots.entries(slot))
+    ctx.effect(() => () => { controller.dispose() }, `ui-settings-plugins: ${slot} directory`)
+    // A card registered after the first read joins the list without a wire call.
+    ctx.effect(() => ctx.slots.subscribe(slot, () => { controller.refresh() }), `ui-settings-plugins: ${slot} ledger`)
+    return controller
+  }
+  const configurable = directory('settings.plugin.item')
+  const presetSettings = directory('settings.agentPreset.plugin.item')
 
   let tabsVersion = -1
   let tabsRevision = -1
@@ -189,44 +183,36 @@ export function apply(ctx: ClientContext): void {
     order: 0,
     label: () => t('configurableTab'),
     locale: NS,
-    inject: () => {
-      const tab = configurable.inject()
-      const scope = switcher.inject()
-      return { ...tab, ...scope, hooks: { ...tab.hooks, ...scope.hooks } }
-    },
+    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)
+  // The settings section of every preset's detail page: the same cards under
+  // the preset's scope, after the plugin manager's capabilities section.
+  ctx.slots.inject('settings.agentPreset.detail', () => ctx.slots.register({
+    name: 'settings.agentPreset.detail',
+    id: 'settings',
+    order: 10,
+    locale: NS,
+    inject: (): PresetSettingsFace => ({
+      ...presetSettings.inject(),
+      selectScope: (scope) => { selection.select(scope ?? undefined) },
+    }),
+    children: { 'settings.agentPreset.plugin.item': { kind: 'keyed', scope: 'root' } },
+  }, PresetSettingsSection))
+
+  // The shipped cards, under both surfaces.
+  const cards = (name: CardSlot) => function* () {
+    yield ctx.slots.register({ name, key: SHELL_NS, locale: NS, inject: () => bash.inject() }, BashCard)
+    yield ctx.slots.register({ name, 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(),
+      name, key: SUBAGENT_MODEL_SELECTION_NS, locale: NS, inject: () => subagentModelSelection.inject(),
     }, SubagentModelSelectionCard)
+    yield ctx.slots.register({ name, key: WEB_SEARCH_NS, locale: NS, inject: () => webSearch.inject() }, WebSearchCard)
     yield ctx.slots.register({
-      name: 'settings.plugin.item',
-      key: WEB_SEARCH_NS,
-      locale: NS,
-      inject: () => webSearch.inject(),
-    }, WebSearchCard)
-    yield ctx.slots.register({
-      name: 'settings.plugin.item',
-      key: SKILL_FILESYSTEM_NS,
-      locale: NS,
-      inject: () => skillFilesystem.inject(),
+      name, key: SKILL_FILESYSTEM_NS, locale: NS, inject: () => skillFilesystem.inject(),
     }, SkillFilesystemCard)
-  })
+  }
+  ctx.slots.inject('settings.plugin.item', cards('settings.plugin.item'))
+  ctx.slots.inject('settings.agentPreset.plugin.item', cards('settings.agentPreset.plugin.item'))
 }

+ 5 - 21
packages/client/ui-settings-plugins/src/client/locales.ts

@@ -2,9 +2,7 @@
 
 /** Locale keys these surfaces render. */
 export type PluginsSettingsLocaleKey =
-  | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
-  | 'scopeLabel' | 'scopeSwitcherLabel' | 'scopeGlobal' | 'scopePresetDefault' | 'scopePresetBroken'
-  | 'scopeHintGlobal' | 'scopeHintPreset' | 'scopeRosterFailed' | 'notMounted'
+  | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty' | 'presetSettingsTitle' | 'notMounted'
   | 'overridden' | 'inherited' | 'reset' | 'readOnly' | 'expand' | 'collapse'
   | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
   | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
@@ -30,15 +28,8 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
   tabs: 'Plugin views',
   configurableTab: 'Plugin configuration',
   empty: 'This deployment exposes no plugin settings.',
-  scopeLabel: 'Applies to',
-  scopeSwitcherLabel: 'Choose which agent preset these settings apply to',
-  scopeGlobal: 'All presets',
-  scopePresetDefault: '{name} (default)',
-  scopePresetBroken: '{name} (failed to load)',
-  scopeHintGlobal: 'Values here apply to every agent preset unless a preset overrides them.',
-  scopeHintPreset: 'Values here apply to this preset only; a field it does not override inherits the value for all presets.',
-  scopeRosterFailed: 'Agent presets could not be listed; only the shared settings are editable.',
-  notMounted: 'This preset does not compose the plugin; saved values take effect once a preset does.',
+  presetSettingsTitle: 'Settings',
+  notMounted: 'No running session composed from this preset has this plugin loaded; saved values apply to the sessions that load it.',
   overridden: 'Overridden',
   inherited: 'Inherited',
   reset: 'Reset to default',
@@ -100,15 +91,8 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   tabs: '插件视图',
   configurableTab: '插件配置',
   empty: '本部署没有开放任何插件设置。',
-  scopeLabel: '生效范围',
-  scopeSwitcherLabel: '选择这些设置生效的 Agent 预设',
-  scopeGlobal: '所有预设',
-  scopePresetDefault: '{name}(默认)',
-  scopePresetBroken: '{name}(加载失败)',
-  scopeHintGlobal: '这里的值对所有 Agent 预设生效,除非某个预设单独覆盖。',
-  scopeHintPreset: '这里的值只对本预设生效;未覆盖的字段继承"所有预设"的值。',
-  scopeRosterFailed: '暂时无法列出 Agent 预设,只能编辑共用设置。',
-  notMounted: '本预设未组合这个插件;保存的值会在某个预设组合它之后生效。',
+  presetSettingsTitle: '设置',
+  notMounted: '本预设当前没有运行中的会话加载这个插件;保存的值会在加载它的会话中生效。',
   overridden: '已覆盖',
   inherited: '继承',
   reset: '恢复默认',

+ 0 - 168
packages/client/ui-settings-plugins/src/client/scope-switcher.ts

@@ -1,168 +0,0 @@
-/**
- * The configurable tab's scope switch: the global instance, or one agent
- * preset's named scope. The options come from the preset roster when the
- * deployment composes one, plus any named scope the settings document
- * already holds a section for — a scope whose preset was deleted still
- * shows, under its raw id, so its section is never hidden.
- */
-
-import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
-import type { ScopeSelection } from './scoped-form.ts'
-
-/** Scope id prefix the agent-preset roster names its standing scopes with. */
-export const PRESET_SCOPE_PREFIX = 'preset/'
-
-/** One roster preset as the switch lists it. */
-export interface PresetScopeRow {
-  /** The preset id; its scope is `preset/<id>`. */
-  readonly id: string
-  /** Shipped or locally authored, which decides how its name resolves. */
-  readonly trust: 'system' | 'user'
-  /** Locally authored display name, when the preset declares one. */
-  readonly name?: string
-  /** Whether a session that names no preset is composed from it. */
-  readonly isDefault: boolean
-  /** Why discovery could not load the preset, when it could not. */
-  readonly broken?: string
-}
-
-/** What the switch renders. */
-export interface ScopeSwitcherState {
-  /** The selected scope; undefined for the global instance. */
-  scope: string | undefined
-  /** Roster presets, in roster order. */
-  presets: readonly PresetScopeRow[]
-  /** Named scopes the document holds that no roster preset owns, as raw ids. */
-  extraScopes: readonly string[]
-  /** Roster request state; `idle` until the tab first renders. */
-  status: 'idle' | 'loading' | 'ready' | 'error'
-}
-
-/** The registration-side face the tab injects for its switch. */
-export interface ScopeSwitcherFace {
-  hooks: {
-    /** Switch snapshot bound by the renderer as useScopeSwitcher. */
-    scopeSwitcher: SnapshotStore<ScopeSwitcherState>
-  }
-  /** Select the scope every card edits; null selects the global instance. */
-  selectScope: (scope: string | null) => void
-  /** Read the roster the first time the tab renders, and retry after a failure. */
-  ensureScopes: () => void
-  /** Display name for one roster preset, resolved through the agent-preset dictionaries. */
-  presetName: (preset: PresetScopeRow) => string
-}
-
-/** Answers the roster, when the deployment composes one; undefined when the Host refused it. */
-export type RosterReader = () => Promise<readonly PresetScopeRow[] | undefined>
-
-/** Derives the switch options from the roster and the global describe answer. */
-export class ScopeSwitcherController {
-  private readonly store: SnapshotStore<ScopeSwitcherState>
-  private presets: readonly PresetScopeRow[] = []
-  private status: ScopeSwitcherState['status'] = 'idle'
-  private generation = 0
-  private disposed = false
-  private readonly disposers: Array<() => void>
-
-  /**
-   * @param selection - the scope selection shared with every card.
-   * @param describeFace - the global describe face, whose `scopes` names the document's sections.
-   * @param roster - reads the preset roster; undefined when the deployment composes none.
-   * @param presetName - display name for one roster preset.
-   */
-  constructor(
-    private readonly selection: ScopeSelection,
-    private readonly describeFace: SettingsDescribeFace,
-    private readonly roster: RosterReader | undefined,
-    private readonly presetName: (preset: PresetScopeRow) => string,
-  ) {
-    this.store = createSnapshotStore(this.projection())
-    this.disposers = [
-      selection.subscribe(() => { this.publish() }),
-      describeFace.subscribe(() => { this.publish() }),
-    ]
-  }
-
-  /** Stop following the selection and the mirror, and drop late roster answers. */
-  dispose(): void {
-    this.disposed = true
-    this.generation += 1
-    for (const dispose of this.disposers) dispose()
-  }
-
-  /**
-   * Re-read the roster after a (re)connect: the Host behind the page may differ.
-   */
-  reset(): void {
-    if (this.disposed) return
-    this.generation += 1
-    this.status = 'idle'
-    this.presets = []
-    this.publish()
-  }
-
-  /**
-   * Build the face the tab's slot registration injects.
-   * @returns the switch snapshot and its actions.
-   */
-  inject(): ScopeSwitcherFace {
-    return {
-      hooks: { scopeSwitcher: this.store },
-      selectScope: (scope) => { this.selection.select(scope ?? undefined) },
-      ensureScopes: () => { void this.load() },
-      presetName: this.presetName,
-    }
-  }
-
-  /**
-   * Read the roster once from `idle` or `error`; a read in flight or a held
-   * answer is kept.
-   * @returns settlement after the answer is published.
-   */
-  async load(): Promise<void> {
-    if (this.disposed || this.status === 'loading' || this.status === 'ready') return
-    if (this.roster === undefined) {
-      this.status = 'ready'
-      this.publish()
-      return
-    }
-    const generation = ++this.generation
-    this.status = 'loading'
-    this.publish()
-    const presets = await this.roster()
-    // Disposal and reset both advance the generation, which is the one
-    // liveness check a settled read needs.
-    if (generation !== this.generation) return
-    if (presets === undefined) {
-      this.status = 'error'
-    } else {
-      this.presets = presets
-      this.status = 'ready'
-    }
-    this.publish()
-  }
-
-  private projection(): ScopeSwitcherState {
-    const owned = new Set(this.presets.map(preset => `${PRESET_SCOPE_PREFIX}${preset.id}`))
-    const described = this.describeFace.getSnapshot().view?.scopes ?? []
-    return {
-      scope: this.selection.current(),
-      presets: this.presets,
-      extraScopes: described.filter(scope => !owned.has(scope)),
-      status: this.status,
-    }
-  }
-
-  private publish(): void {
-    const next = this.projection()
-    const previous = this.store.getSnapshot()
-    // Every settings commit refreshes the mirror; keep the reference until a
-    // fact moves (packages/client/AGENTS.md reactive rule 5).
-    if (previous.scope === next.scope && previous.status === next.status
-      && previous.presets === next.presets
-      && previous.extraScopes.length === next.extraScopes.length
-      && previous.extraScopes.every((scope, index) => scope === next.extraScopes[index])) return
-    this.store.set(next)
-  }
-}

+ 7 - 6
packages/client/ui-settings-plugins/src/client/scoped-form.ts

@@ -1,14 +1,15 @@
 /**
  * The scope a card edits, and the per-scope forms behind one card.
  *
- * The configurable tab offers one scope switch over every card: the global
- * instance, or one agent preset's named scope. A card controller stages
+ * The cards edit one scope at a time: the global instance on the
+ * configuration tab, or one agent preset's named scope while that preset's
+ * detail page is open. A card controller stages
  * edits through {@link ScopedCardForms}, which keeps one {@link CardForm} per
  * scope — drafts belong to the scope they were typed under and survive a
  * switch — and re-projects the selected scope's form whenever the selection
  * or that form moves. Card components stay unaware of scopes: they read the
  * same hooks and call the same actions, which route to the selected form at
- * call time.
+ * call time, so one registration serves both surfaces.
  */
 
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -17,7 +18,7 @@ import {
   CardForm, type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell,
 } from './card-form.ts'
 
-/** The scope the configurable tab is editing. */
+/** The scope the cards are editing. */
 export interface ScopeSelectionState {
   /** The named settings scope, such as `preset/<id>`; undefined for the global instance. */
   scope: string | undefined
@@ -26,7 +27,7 @@ export interface ScopeSelectionState {
 /** Map key of the global instance inside {@link ScopedCardForms}. */
 const GLOBAL_KEY = ''
 
-/** The one scope selection the configurable tab and every card share. */
+/** The one scope selection the card surfaces and every card share. */
 export class ScopeSelection {
   private readonly store = createSnapshotStore<ScopeSelectionState>({ scope: undefined })
 
@@ -80,7 +81,7 @@ export class ScopedCardForms<T> {
   private readonly listeners = new Set<() => void>()
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the card's namespace under one scope.
    * @param specs - the section fields the card edits.
    * @param secrets - the card's write-only controls, written outside the section.

+ 1 - 1
packages/client/ui-settings-plugins/src/client/skill-filesystem-card-controller.ts

@@ -36,7 +36,7 @@ export class SkillFilesystemCardController {
   private readonly store: SnapshotStore<SkillFilesystemCardState>
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the `skill-filesystem` namespace under one scope.
    */
   constructor(selection: ScopeSelection, bindScope: BindScope<SkillFilesystemSettings>) {

+ 20 - 11
packages/client/ui-settings-plugins/src/client/slot-contract.ts

@@ -1,26 +1,35 @@
 /**
- * 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.
+ * The plugin card slots — one plugin's card, keyed by the settings namespace
+ * the card edits. Options: `key` (the namespace). A card draws its own
+ * internals; the surface only decides which namespaces to dispatch and stacks
+ * what comes back. `settings.plugin.item` is the configuration tab, where a
+ * card edits the global instance of its namespace; `settings.agentPreset.plugin.item`
+ * is the settings section of a preset's detail page, where the same card
+ * edits that preset's own scope. A card registers under both to appear on
+ * both surfaces.
  *
  * 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.
+ * Host and its own card under that key in the browser, and the surface 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.
+ * TYPE HOME RATIONALE: this package declares both slots at runtime, and a
+ * plugin registering its own card already depends on this package for the
+ * declarations. The types therefore live with their declarer.
  */
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
-    /** One plugin's card inside the plugin configuration section (see module JSDoc). */
+    /** One plugin's card inside the plugin configuration tab (see module JSDoc). */
     'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
+    /** One plugin's card inside a preset's settings section (see module JSDoc). */
+    'settings.agentPreset.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
   }
 }
 
-/** Owner share of a plugin card (the section supplies nothing). */
+/** The two slots a plugin card registers under. */
+export type CardSlot = 'settings.plugin.item' | 'settings.agentPreset.plugin.item'
+
+/** Owner share of a plugin card (the surface supplies nothing). */
 export interface SettingsPluginItemOwnerProps {
   /** Marker field: card owner props are intentionally empty. */
   children?: never

+ 1 - 1
packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts

@@ -149,7 +149,7 @@ export class SubagentModelSelectionCardController {
   private readonly unsubscribe: () => void
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the `subagent-model-selection` namespace under one scope.
    * @param ctx - the card plugin's context, whose `remote.session` namespace
    * answers the Host model catalog.

+ 1 - 1
packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts

@@ -81,7 +81,7 @@ export class WebSearchCardController {
   private credential: CredentialState = { ref: '', configured: false, writable: true }
 
   /**
-   * @param selection - the scope selection shared with the tab.
+   * @param selection - the scope selection shared with the card surfaces.
    * @param bindScope - binds the `web-search-deepseek` namespace under one scope.
    * @param ctx - the card plugin's context, whose `remote.credentials` namespace
    * answers for the credential the section references.

+ 31 - 59
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -9,7 +9,7 @@ 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 {
-  BashCardFace, ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, ScopeSwitcherFace,
+  BashCardFace, ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, PresetSettingsFace,
 } 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'
@@ -48,29 +48,24 @@ async function bench(served?: string[]) {
         })),
       },
     }))
-  const listPresets = vi.fn(() => Promise.resolve({
-    ok: true as const,
-    value: {
-      authorable: true,
-      presets: [{ id: 'standard', trust: 'system' as const, isDefault: true }],
-    },
-  }))
   const remote = new TestRemote(ctx, {
-    agentPresets: { list: listPresets },
     credentials: { describe: describeCredentials, set: vi.fn() },
     session: { modelCatalog: models },
     settings: { describe: describeSettings },
   })
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return {
-    ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, listPresets, models, remote,
+    ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
   }
 }
 
 function declareRoot(slots: SlotRegistry): () => void {
   return slots.register({
     name: 'root',
-    children: { 'settings.section': { kind: 'list', scope: 'root' } },
+    children: {
+      'settings.section': { kind: 'list', scope: 'root' },
+      'settings.agentPreset.detail': { kind: 'list', scope: 'root' },
+    },
   } as never, () => null)
 }
 
@@ -80,12 +75,10 @@ describe('ui-settings-plugins apply', () => {
   })
 
   it('declares the services it uses', () => {
-    expect(inject).toEqual([
-      'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.credentials', 'remote.session', 'settingsScope',
-    ])
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope'])
   })
 
-  it('registers one Plugins section and declares the tab and card slots', async () => {
+  it('registers one Plugins section and one preset detail section, and declares the tab and card slots', async () => {
     const { ctx, slots } = await bench()
     declareRoot(slots)
 
@@ -100,6 +93,10 @@ describe('ui-settings-plugins apply', () => {
     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' })
+    // The preset detail section follows the plugin manager's capabilities section.
+    const detail = slots.entries('settings.agentPreset.detail')[0]!
+    expect(detail.options).toMatchObject({ id: 'settings', order: 10 })
+    expect(slots.spec('settings.agentPreset.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
   })
 
 
@@ -127,7 +124,7 @@ describe('ui-settings-plugins apply', () => {
 
     const tab = slots.entries('settings.plugins.tab')[0]!
     const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
-    expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins', 'scopeSwitcher'])
+    expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
     for (const entry of slots.entries('settings.plugin.item')) {
       const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
       // Each card injects exactly one snapshot store plus its own actions.
@@ -141,8 +138,9 @@ describe('ui-settings-plugins apply', () => {
 
     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', 'skill-filesystem'])
+    const keys = ['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek', 'skill-filesystem']
+    expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)).toEqual(keys)
+    expect(slots.entries('settings.agentPreset.plugin.item').map(entry => entry.options.key)).toEqual(keys)
   })
 
   it('dispatches the served namespaces its cards claim, and no others', async () => {
@@ -231,27 +229,19 @@ describe('ui-settings-plugins apply', () => {
     expect(describeCredentials).not.toHaveBeenCalled()
   })
 
-  it('lists the roster on first use and switches every card to the selected preset scope', async () => {
-    const { ctx, slots, describeSettings, listPresets, remote } = await bench(['shell'])
+  it('switches every card to a preset scope while its detail page holds the selection', async () => {
+    const { ctx, slots, describeSettings, remote } = await bench(['shell'])
     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 & ScopeSwitcherFace)()
-    expect(listPresets).not.toHaveBeenCalled()
-    face.ensureScopes()
-    await vi.waitFor(() => {
-      expect(face.hooks.scopeSwitcher.getSnapshot()).toMatchObject({
-        status: 'ready', presets: [{ id: 'standard', isDefault: true }],
-      })
-    })
-    // Shipped preset names resolve through the agent-preset dictionaries the
-    // real plugin registers; user-authored metadata stays as declared.
-    ctx.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never)
-    expect(face.presetName({ id: 'standard', trust: 'system', isDefault: true })).toBe('标准模式')
-    expect(face.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false })).toBe('我自己的')
+    const section = slots.entries('settings.agentPreset.detail')[0]!
+    const face = (section.inject as unknown as () => PresetSettingsFace)()
+    await vi.waitFor(() => { expect(face.hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['shell']) })
 
-    const bash = slots.entries('settings.plugin.item')[0]!
+    // One controller serves both slots: the card under the preset slot is the tab's card.
+    const bash = slots.entries('settings.agentPreset.plugin.item')[0]!
     const bashFace = (bash.inject as unknown as () => BashCardFace)()
+    const tabBash = slots.entries('settings.plugin.item')[0]!
+    expect((tabBash.inject as unknown as () => BashCardFace)().hooks.bashCard).toBe(bashFace.hooks.bashCard)
     await vi.waitFor(() => { expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: undefined, registered: true }) })
     describeSettings.mockClear()
     face.selectScope('preset/standard')
@@ -260,35 +250,14 @@ describe('ui-settings-plugins apply', () => {
     await vi.waitFor(() => {
       expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: 'preset/standard', registered: false })
     })
-    expect(face.hooks.scopeSwitcher.getSnapshot().scope).toBe('preset/standard')
     // A scoped commit reloads the scoped mirror only.
     describeSettings.mockClear()
     remote.emit('settings/document-updated', ['shell', 1, 'preset/standard'])
     await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalledTimes(1) })
     expect(describeSettings).toHaveBeenCalledWith('preset/standard')
-    // A reconnect re-reads the roster on the next use.
-    ctx.emit('connection/reset')
-    expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('idle')
-    face.ensureScopes()
-    await vi.waitFor(() => { expect(listPresets).toHaveBeenCalledTimes(2) })
-  })
-
-  it('reports a roster the Host refused or a transport that failed without losing the global instance', async () => {
-    const { ctx, slots, listPresets } = await bench(['shell'])
-    listPresets
-      .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'no roster', {}) } as never)
-      .mockRejectedValueOnce(new Error('offline'))
-    declareRoot(slots)
-    await ctx.plugin({ inject: [...inject], apply }).await()
-    const tab = slots.entries('settings.plugins.tab')[0]!
-    const face = (tab.inject as unknown as () => ScopeSwitcherFace)()
-    face.ensureScopes()
-    await vi.waitFor(() => { expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('error') })
-    expect(face.hooks.scopeSwitcher.getSnapshot()).toMatchObject({ scope: undefined, presets: [] })
-    ctx.emit('connection/reset')
-    face.ensureScopes()
-    await vi.waitFor(() => { expect(listPresets).toHaveBeenCalledTimes(2) })
-    await vi.waitFor(() => { expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('error') })
+    // Leaving the page returns every card to the global instance.
+    face.selectScope(null)
+    expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: undefined, registered: true })
   })
 
   it('registers into a declaration that arrives after apply', async () => {
@@ -306,11 +275,14 @@ describe('ui-settings-plugins apply', () => {
     const fiber = ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
     expect(slots.entries('settings.plugin.item')).toHaveLength(5)
+    expect(slots.entries('settings.agentPreset.plugin.item')).toHaveLength(5)
 
     await fiber.dispose()
 
     expect(slots.entries('settings.section')).toHaveLength(0)
+    expect(slots.entries('settings.agentPreset.detail')).toHaveLength(0)
     expect(slots.spec('settings.plugins.tab')).toBeUndefined()
     expect(slots.spec('settings.plugin.item')).toBeUndefined()
+    expect(slots.spec('settings.agentPreset.plugin.item')).toBeUndefined()
   })
 })

+ 0 - 116
packages/client/ui-settings-plugins/tests/scope-switcher.client.spec.ts

@@ -1,116 +0,0 @@
-/**
- * The scope switch's options: roster presets, document-only scopes, and the
- * roster request lifecycle.
- */
-
-import { describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { SettingsDescribeFace, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { ScopeSelection } from '../src/client/scoped-form.ts'
-import { ScopeSwitcherController, type PresetScopeRow } from '../src/client/scope-switcher.ts'
-
-const STANDARD: PresetScopeRow = { id: 'standard', trust: 'system', isDefault: true }
-
-function describeFace(scopes: readonly string[] = []) {
-  const store = createSnapshotStore<SettingsMirrorSnapshot>({
-    status: 'ready',
-    view: { writable: true, hasDocument: true, namespaces: [], scopes },
-    error: null,
-  })
-  const face: SettingsDescribeFace = {
-    getSnapshot: () => store.getSnapshot(),
-    subscribe: listener => store.subscribe(listener),
-    ensure: () => Promise.resolve(),
-    acceptView: () => {},
-  }
-  return { face, store }
-}
-
-function deferred<T>() {
-  let resolve!: (value: T) => void
-  let reject!: (reason: unknown) => void
-  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
-  return { promise, resolve, reject }
-}
-
-describe('ScopeSwitcherController', () => {
-  it('reads the roster once from idle, lists document-only scopes, and follows the selection', async () => {
-    const selection = new ScopeSelection()
-    const { face, store } = describeFace(['preset/standard', 'preset/deleted'])
-    const roster = vi.fn(() => Promise.resolve([STANDARD]))
-    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.name ?? preset.id)
-    const injected = controller.inject()
-    expect(injected.hooks.scopeSwitcher.getSnapshot()).toEqual({
-      scope: undefined, presets: [], extraScopes: ['preset/standard', 'preset/deleted'], status: 'idle',
-    })
-    injected.ensureScopes()
-    injected.ensureScopes()
-    expect(injected.hooks.scopeSwitcher.getSnapshot().status).toBe('loading')
-    await vi.waitFor(() => { expect(injected.hooks.scopeSwitcher.getSnapshot().status).toBe('ready') })
-    expect(roster).toHaveBeenCalledTimes(1)
-    expect(injected.hooks.scopeSwitcher.getSnapshot()).toMatchObject({
-      presets: [STANDARD], extraScopes: ['preset/deleted'],
-    })
-    injected.ensureScopes()
-    expect(roster).toHaveBeenCalledTimes(1)
-    expect(injected.presetName(STANDARD)).toBe('standard')
-
-    const before = injected.hooks.scopeSwitcher.getSnapshot()
-    // An unrelated mirror refresh keeps the reference.
-    store.set({ ...store.getSnapshot() })
-    expect(injected.hooks.scopeSwitcher.getSnapshot()).toBe(before)
-    injected.selectScope('preset/standard')
-    expect(injected.hooks.scopeSwitcher.getSnapshot().scope).toBe('preset/standard')
-    injected.selectScope(null)
-    expect(selection.current()).toBeUndefined()
-    // A document gaining a scope section shows it.
-    store.set({ ...store.getSnapshot(), view: { writable: true, hasDocument: true, namespaces: [], scopes: ['preset/x'] } })
-    expect(injected.hooks.scopeSwitcher.getSnapshot().extraScopes).toEqual(['preset/x'])
-    controller.dispose()
-    const after = injected.hooks.scopeSwitcher.getSnapshot()
-    injected.selectScope('preset/x')
-    expect(injected.hooks.scopeSwitcher.getSnapshot()).toBe(after)
-  })
-
-  it('reports a failed roster, retries it, and answers ready at once without a roster', async () => {
-    const selection = new ScopeSelection()
-    const { face } = describeFace()
-    const roster = vi.fn()
-      .mockResolvedValueOnce(undefined)
-      .mockResolvedValueOnce([STANDARD])
-    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.id)
-    await controller.load()
-    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().status).toBe('error')
-    await controller.load()
-    expect(controller.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'ready', presets: [STANDARD] })
-
-    const bare = new ScopeSwitcherController(selection, face, undefined, preset => preset.id)
-    await bare.load()
-    expect(bare.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'ready', presets: [] })
-  })
-
-  it('drops a late answer after a reset or disposal', async () => {
-    const selection = new ScopeSelection()
-    const { face } = describeFace()
-    const first = deferred<readonly PresetScopeRow[]>()
-    const roster = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValueOnce([STANDARD])
-    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.id)
-    const loading = controller.load()
-    controller.reset()
-    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().status).toBe('idle')
-    first.resolve([{ id: 'stale', trust: 'user', isDefault: false }])
-    await loading
-    expect(controller.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'idle', presets: [] })
-    await controller.load()
-    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().presets).toEqual([STANDARD])
-
-    const second = deferred<readonly PresetScopeRow[]>()
-    const late = new ScopeSwitcherController(selection, face, () => second.promise, preset => preset.id)
-    const pending = late.load()
-    late.dispose()
-    late.reset()
-    second.resolve([STANDARD])
-    await pending
-    expect(late.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'loading', presets: [] })
-  })
-})

+ 47 - 61
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -13,8 +13,9 @@ import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePlug
 import { SkillFilesystemCard } from '../src/client/SkillFilesystemCard.tsx'
 import type { SkillFilesystemCardProps } from '../src/client/SkillFilesystemCard.tsx'
 import type { SkillFilesystemCardState } from '../src/client/skill-filesystem-card-controller.ts'
-import type { ScopeSwitcherState } from '../src/client/scope-switcher.ts'
 import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
+import { PresetSettingsSection } from '../src/client/PresetSettingsSection.tsx'
+import type { PresetSettingsSectionProps } from '../src/client/PresetSettingsSection.tsx'
 import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
 import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
 import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
@@ -63,30 +64,36 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
   render(<PluginsSettingsSection {...props} />)
 }
 
-function renderConfigurable(
-  namespaces: string[],
-  cards: Record<string, string> = {},
-  loaded = true,
-  switcher: Partial<ScopeSwitcherState> = {},
-) {
+function cardSlot(cards: Record<string, string>) {
+  return (_name: string, _owner: object, opts?: { entryKey?: string }) => {
+    const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
+    return card === undefined ? null : <li>{card}</li>
+  }
+}
+
+function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
   const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
-  const switcherStore = createSnapshotStore<ScopeSwitcherState>({
-    scope: undefined, presets: [], extraScopes: [], status: 'ready', ...switcher,
-  })
-  const actions = { ensureScopes: vi.fn(), selectScope: vi.fn() }
   const props = {
     t,
-    ...actions,
-    presetName: (preset: { id: string; name?: string }) => preset.name ?? preset.id,
     useConfigurablePlugins: bindSnapshotSelector(store),
-    useScopeSwitcher: bindSnapshotSelector(switcherStore),
-    renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
-      const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
-      return card === undefined ? null : <li>{card}</li>
-    },
+    renderSlot: cardSlot(cards),
   } as unknown as ConfigurablePluginsTabProps
   render(<ConfigurablePluginsTab {...props} />)
-  return actions
+}
+
+function renderPresetSettings(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
+  const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
+  const selectScope = vi.fn()
+  const props = {
+    t,
+    presetId: 'standard',
+    presetName: '标准模式',
+    selectScope,
+    useConfigurablePlugins: bindSnapshotSelector(store),
+    renderSlot: cardSlot(cards),
+  } as unknown as PresetSettingsSectionProps
+  const view = render(<PresetSettingsSection {...props} />)
+  return { selectScope, unmount: view.unmount }
 }
 
 function renderSkillFilesystemCard(state: Partial<SkillFilesystemCardState> = {}) {
@@ -238,50 +245,29 @@ describe('ConfigurablePluginsTab', () => {
   })
 })
 
-describe('ConfigurablePluginsTab scope switch', () => {
-  it('asks for the roster once mounted and offers the global instance plus every preset', () => {
-    const actions = renderConfigurable([], {}, true, {
-      presets: [
-        { id: 'standard', trust: 'system', name: '标准模式', isDefault: true },
-        { id: 'research', trust: 'user', name: 'Research', isDefault: false },
-        { id: 'broken', trust: 'user', isDefault: false, broken: 'bad yaml' },
-      ],
-      extraScopes: ['preset/deleted'],
-    })
-    expect(actions.ensureScopes).toHaveBeenCalledTimes(1)
-    const trigger = screen.getByRole('button', { name: en.scopeSwitcherLabel })
-    expect(trigger.textContent).toBe(en.scopeGlobal)
-    expect(trigger.getAttribute('data-settings-scope')).toBe('global')
-    expect(screen.getByText(en.scopeHintGlobal)).toBeTruthy()
-
-    fireEvent.click(trigger)
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('menuitem')).toBeNull()
-    fireEvent.click(trigger)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Research' }))
-    expect(actions.selectScope).toHaveBeenCalledWith('preset/research')
-    fireEvent.click(trigger)
-    expect(screen.getByRole('menuitem', { name: '标准模式 (default)' })).toBeTruthy()
-    expect(screen.getByRole('menuitem', { name: 'broken (failed to load)' })).toBeTruthy()
-    expect(screen.getByRole('menuitem', { name: 'preset/deleted' })).toBeTruthy()
-    fireEvent.click(screen.getByRole('menuitem', { name: en.scopeGlobal }))
-    expect(actions.selectScope).toHaveBeenLastCalledWith(null)
-  })
-
-  it('names the selected scope, keeps an unlisted one visible by id, and reports a roster failure', () => {
-    renderConfigurable([], {}, true, {
-      scope: 'preset/research',
-      presets: [{ id: 'research', trust: 'user', name: 'Research', isDefault: false }],
-    })
-    const trigger = screen.getByRole('button', { name: en.scopeSwitcherLabel })
-    expect(trigger.textContent).toBe('Research')
-    expect(trigger.getAttribute('data-settings-scope')).toBe('preset/research')
-    expect(screen.getByText(en.scopeHintPreset)).toBeTruthy()
+describe('PresetSettingsSection', () => {
+  it('holds the preset scope while mounted, dispatches the cards, and returns to the global instance on unmount', () => {
+    const { selectScope, unmount } = renderPresetSettings(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
+
+    expect(selectScope).toHaveBeenCalledTimes(1)
+    expect(selectScope).toHaveBeenCalledWith('preset/standard')
+    expect(screen.getByRole('heading', { name: en.presetSettingsTitle })).toBeTruthy()
+    expect(document.querySelector('[data-settings-scope="preset/standard"]')).toBeTruthy()
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
+    expect(screen.queryByText(en.empty)).toBeNull()
+
+    unmount()
+    expect(selectScope).toHaveBeenLastCalledWith(null)
+  })
+
+  it('withholds the empty line until the Host has answered once', () => {
+    renderPresetSettings([], { bash: 'shell' }, false)
+    expect(screen.queryByText(en.empty)).toBeNull()
     cleanup()
 
-    renderConfigurable([], {}, true, { scope: 'preset/gone', status: 'error' })
-    expect(screen.getByRole('button', { name: en.scopeSwitcherLabel }).textContent).toBe('preset/gone')
-    expect(screen.getByText(en.scopeRosterFailed)).toBeTruthy()
+    renderPresetSettings([], { bash: 'shell' })
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    expect(screen.queryByText('shell')).toBeNull()
   })
 })
 

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

@@ -37,9 +37,6 @@
     },
     {
       "path": "../ui-agent-preset"
-    },
-    {
-      "path": "../../preset/agent-presets"
     }
   ]
 }

+ 0 - 4
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -861,10 +861,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'ThemeTokens',
     declaration: 'export type ThemeTokens = Record<string, string>;',
   },
-  {
-    name: 'Translate',
-    declaration: 'export type Translate<K extends string = string> = (key: K, params?: Record<string, unknown>) => string;',
-  },
   {
     name: 'TranslateNS',
     declaration: 'export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>;',

+ 86 - 11
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1441,6 +1441,87 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.action\', () => ctx.slots.register(\n      { name: \'settings.action\', 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:36',
   },
+  {
+    key: 'settings.agentPreset.detail',
+    kind: 'list',
+    scope: 'root',
+    summary: 'One section of a preset\'s detail page (see module JSDoc).',
+    doc: 'One section of a preset\'s detail page (see module JSDoc).',
+    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: [
+      '/** Owner share of a detail section: which preset the page shows. */\nexport interface AgentPresetDetailOwnerProps {\n  /** The preset\'s id. */\n  readonly presetId: string\n  /** The preset\'s display name, resolved through the roster dictionaries. */\n  readonly presetName: string\n}',
+    ],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'settings.section\' (client-ui-agent-preset), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-settings-plugin-manager PresetPluginsSection id \'plugins\'',
+      'client-ui-settings-plugins PresetSettingsSection id \'settings\'',
+    ],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.agentPreset.detail\', () => ctx.slots.register(\n      { name: \'settings.agentPreset.detail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-agent-preset/src/client/slot-contract.ts:15',
+  },
+  {
+    key: 'settings.agentPreset.plugin.item',
+    kind: 'keyed',
+    scope: 'root',
+    summary: 'One plugin\'s card inside a preset\'s settings section (see module JSDoc).',
+    doc: 'One plugin\'s card inside a preset\'s settings 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 surface supplies nothing). */\nexport interface SettingsPluginItemOwnerProps {\n  /** Marker field: card owner props are intentionally empty. */\n  children?: never\n}',
+    ],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      '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.agentPreset.detail\' (client-ui-settings-plugins), so it exists while that entry is mounted',
+    occupants: [],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.agentPreset.plugin.item\', () => ctx.slots.register(\n      { name: \'settings.agentPreset.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:25',
+  },
   {
     key: 'settings.close',
     kind: 'single',
@@ -1681,8 +1762,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     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).',
+    summary: 'One plugin\'s card inside the plugin configuration tab (see module JSDoc).',
+    doc: 'One plugin\'s card inside the plugin configuration tab (see module JSDoc).',
     registerOptions: [
       {
         name: 'key',
@@ -1692,7 +1773,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     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}',
+      '/** Owner share of a plugin card (the surface supplies nothing). */\nexport interface SettingsPluginItemOwnerProps {\n  /** Marker field: card owner props are intentionally empty. */\n  children?: never\n}',
     ],
     ownerPropsReferences: [],
     standardProps: [
@@ -1705,16 +1786,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     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',
-      'client-ui-settings-plugins SkillFilesystemCard',
-    ],
+    occupants: [],
     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',
+    source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:23',
   },
   {
     key: 'settings.plugins.tab',

+ 0 - 3
pnpm-lock.yaml

@@ -3224,9 +3224,6 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-agent-presets':
-        specifier: workspace:^
-        version: link:../../preset/agent-presets
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes