Pārlūkot izejas kodu

feat(web): unify Subagent settings UI

ZiyaZhang 2 nedēļas atpakaļ
vecāks
revīzija
ea2651b86b
30 mainītis faili ar 802 papildinājumiem un 282 dzēšanām
  1. 6 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml
  2. 29 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md
  3. 29 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md
  4. 2 6
      apps/web/tests/expected/plugin-config/section.expected.md
  5. 25 19
      apps/web/tests/expected/plugin-config/subagent.expected.md
  6. 46 16
      apps/web/tests/plugin-config.e2e.ts
  7. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  8. 5 3
      packages/client/ui-settings-plugins/README.md
  9. 5 3
      packages/client/ui-settings-plugins/README.zh.md
  10. 1 1
      packages/client/ui-settings-plugins/src/client/PluginCard.module.css
  11. 16 0
      packages/client/ui-settings-plugins/src/client/SubagentCard.module.css
  12. 51 0
      packages/client/ui-settings-plugins/src/client/SubagentCard.tsx
  13. 0 53
      packages/client/ui-settings-plugins/src/client/SubagentLimitsCard.module.css
  14. 0 63
      packages/client/ui-settings-plugins/src/client/SubagentLimitsCard.tsx
  15. 41 0
      packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css
  16. 57 0
      packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx
  17. 2 2
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css
  18. 12 22
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx
  19. 56 4
      packages/client/ui-settings-plugins/src/client/fields.module.css
  20. 32 7
      packages/client/ui-settings-plugins/src/client/fields.tsx
  21. 11 8
      packages/client/ui-settings-plugins/src/client/index.ts
  22. 33 32
      packages/client/ui-settings-plugins/src/client/locales.ts
  23. 84 0
      packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts
  24. 6 3
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  25. 141 32
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  26. 104 0
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  27. 2 2
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  28. 2 2
      packages/subagent/subagent/README.i18n.yaml
  29. 1 1
      packages/subagent/subagent/README.md
  30. 1 1
      packages/subagent/subagent/README.zh.md

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md
+2026-09-16-shared-subagent-settings-card.md: 57f39d2dffc0ca5965ae5fcf505c7dfde318a844
+2026-09-16-shared-subagent-settings-card.zh.md: ed6244be4726da33b83ea53c8b647068de2446a4

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md

@@ -0,0 +1,29 @@
+# Agent Note: Shared Subagent settings card
+
+Status: implemented
+
+English | [中文](2026-09-16-shared-subagent-settings-card.zh.md)
+
+## Problem
+
+Delegation limits and model authorization describe the same Subagent workflow, but separate settings cards make users locate and save them independently.
+
+## Decision
+
+One Subagent card groups delegation limits and model selection, with one disclosure and save/discard footer. Both existing controllers retain ownership of their drafts, validation and namespace writes. The card blocks saving if either draft is invalid or conflicted, disables both sections during saving, and collapses only after both settle successfully. A failed section retains its draft for retry. Limit explanations stay behind information buttons beside the field labels, with concrete depth examples and counting rules. Validation errors remain visible so disclosure does not hide a blocked save.
+
+Both namespace keys register the shared component. The model registration renders only when the limits namespace is absent, preserving model-only deployments without teaching the generic configurable tab about Subagent grouping.
+
+## Alternatives considered
+
+**Keep separate cards.** Independent controls obscure the relationship between delegation policy and model authorization and require separate save gestures.
+
+**Merge Host namespaces.** UI grouping needs no change to persisted settings, namespace revisions or the different times these policies take effect. The existing namespace controllers preserve those contracts.
+
+## Consequences
+
+The user reviews both sections in one place. A save remains separate namespace writes, so partial success is possible; successful drafts clear while rejected drafts remain visible and retryable. Model authorization still writes its switch and routes atomically within its own namespace. The [package reference](../../../../packages/client/ui-settings-plugins/README.md) describes the controls and their applicability.
+
+## Verification
+
+Focused component and controller tests cover shared validation, save/discard, pending writes, partial failure and the model-only fallback. The assembled Web scenario saves both sections through one button and reads back the settings document.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 统一 Subagent 设置卡片
+
+Status: implemented
+
+[English](2026-09-16-shared-subagent-settings-card.md) | 中文
+
+## Problem
+
+委派限制与模型授权描述的是同一套 Subagent 工作流程,但分开的设置卡片让用户必须分别查找和保存。
+
+## Decision
+
+一张 Subagent 卡片组织委派限制与模型选择,共用一个折叠项以及保存和放弃修改操作。两个现有控制器仍各自拥有草稿、校验和命名空间写入。任一草稿无效或冲突时,卡片禁止保存;保存期间禁用两个分区;两者均成功完成后才收起。失败的分区保留草稿以供重试。限制说明通过字段名旁的信息按钮按需展开,使用具体层级示例和计数规则。校验错误仍直接显示,避免折叠说明后隐藏阻止保存的原因。
+
+两个命名空间键都注册同一个组件。模型注册项仅在限制命名空间缺失时呈现,既保留只提供模型设置的部署,也无需让通用配置标签页了解 Subagent 分组。
+
+## Alternatives considered
+
+**保留分开的卡片。** 独立控件使委派策略与模型授权之间的关系不够清晰,也需要分别保存。
+
+**合并 Host 命名空间。** UI 分组不需要改变持久化设置、命名空间 revision 或两项策略不同的生效时机。现有命名空间控制器保留这些约定。
+
+## Consequences
+
+用户可以在同一处检查两个分区。保存仍分别写入各自的命名空间,因此可能部分成功;成功的草稿清除,被拒绝的草稿保持可见并可重试。模型授权仍在自己的命名空间内原子写入开关与路由。[包参考文档](../../../../packages/client/ui-settings-plugins/README.zh.md) 描述控件及其生效范围。
+
+## Verification
+
+聚焦的组件和控制器测试覆盖共同校验、保存与放弃修改、进行中的写入、部分失败,以及仅提供模型设置的后备显示。组装后的 Web 场景通过一个按钮保存两个分区,并回读设置文档。

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

@@ -36,12 +36,8 @@
           - text: Agent 循环 Agent 如何派发工具调用。
           - img
       - listitem:
-        - 'button "展开设置: Subagent 限制"':
-          - text: Subagent 限制 设置委派的最大深度,以及可同时在线的子代理数量。
-          - img
-      - listitem:
-        - 'button "展开设置: Subagent 模型"':
-          - text: Subagent 模型 控制 Agent 为 Subagent 选择模型的权限。
+        - 'button "展开设置: Subagent"':
+          - text: Subagent 设置 Subagent 的递归层级、数量和模型。
           - img
       - listitem:
         - 'button "展开设置: 网页搜索"':

+ 25 - 19
apps/web/tests/expected/plugin-config/subagent-limits.expected.md → apps/web/tests/expected/plugin-config/subagent.expected.md

@@ -36,28 +36,34 @@
           - text: Agent 循环 Agent 如何派发工具调用。
           - img
       - listitem:
-        - 'button "收起设置: Subagent 限制" [expanded]':
-          - text: Subagent 限制 设置委派的最大深度,以及可同时在线的子代理数量。
+        - 'button "收起设置: Subagent" [expanded]':
+          - text: Subagent 设置 Subagent 的递归层级、数量和模型。
           - img
-        - text: 最大深度 已覆盖
-        - button "恢复默认"
-        - textbox "最大深度":
-          - /placeholder: ""
-          - text: "2"
-        - paragraph: 主代理的深度为 0。设为 1 时仅允许直接子代理;设为 0 时禁用委派。工具显式配置的深度优先。
-        - text: 同时在线上限 已覆盖
-        - button "恢复默认"
-        - textbox "同时在线上限":
-          - /placeholder: ""
-          - text: "12"
-        - paragraph: 同一主代理的所有可续接后代共享额度。等待中的子代理也计入;主代理不计入。
-        - paragraph: 保存后用于后续委派。调低在线上限不会终止已有子代理。一次性运行和外部代理不占用在线额度。
+        - region "运行限制":
+          - heading "运行限制" [level=3]
+          - text: 最大递归深度
+          - button "最大递归深度说明":
+            - img
+          - text: 已覆盖
+          - button "恢复默认"
+          - textbox "最大递归深度":
+            - /placeholder: ""
+            - text: "2"
+          - text: Subagent 总数上限
+          - button "Subagent 总数上限说明":
+            - img
+          - text: 已覆盖
+          - button "恢复默认"
+          - textbox "Subagent 总数上限":
+            - /placeholder: ""
+            - text: "12"
+        - region "模型选择":
+          - heading "模型选择" [level=3]
+          - text: 允许 Agent 为 Subagent 选择模型
+          - switch "允许 Agent 为 Subagent 选择模型"
+          - paragraph: 关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。
         - button "放弃修改" [disabled]
         - button "保存" [disabled]
-      - listitem:
-        - 'button "展开设置: Subagent 模型"':
-          - text: Subagent 模型 控制 Agent 为 Subagent 选择模型的权限。
-          - img
       - listitem:
         - 'button "展开设置: 网页搜索"':
           - text: 网页搜索 DeepSeek 搜索提供方。

+ 46 - 16
apps/web/tests/plugin-config.e2e.ts

@@ -68,14 +68,14 @@ describe('web e2e: plugin configuration section', () => {
     return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '')
   }
 
-  it('shows one card per exposed host-plane namespace', async () => {
+  it('groups both Subagent namespaces into one card', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
     const dialog = await openPlugins()
 
     // Every card the shipped web composition exposes: the shell executor, the
     // agent loop, subagent selection, and the DeepSeek search provider.
-    await dialog.getByText('Subagent 模型', { exact: true }).waitFor({ timeout: 10_000 })
-    expect(await dialog.getByRole('button', { name: '展开设置: Subagent 模型' }).count()).toBe(1)
+    await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
+    expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
     await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
     expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
     expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
@@ -89,39 +89,67 @@ describe('web e2e: plugin configuration section', () => {
 
   it('saves subagent limits and resets them to the deployment defaults', async () => {
     const dialog = await openPlugins()
-    await dialog.getByRole('button', { name: '展开设置: Subagent 限制', exact: true }).click()
-    const depth = dialog.getByLabel('最大深度', { exact: true })
-    const capacity = dialog.getByLabel('同时在线上限', { exact: true })
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).click()
+    const depth = dialog.getByLabel('最大递归深度', { exact: true })
+    const capacity = dialog.getByLabel('Subagent 总数上限', { exact: true })
     expect(await depth.inputValue()).toBe('3')
     expect(await capacity.inputValue()).toBe('8')
     await depth.fill('2')
     await capacity.fill('12')
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    await dialog.getByRole('button', { name: '展开设置: Subagent 限制', exact: true }).waitFor()
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).waitFor()
     await expect.poll(settingsDocument).toContain('maxActiveSubagents: 12')
     await expect.poll(settingsDocument).toContain('maxDepth: 2')
-    await dialog.getByRole('button', { name: '展开设置: Subagent 限制', exact: true }).click()
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).click()
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'subagent-limits.expected.md'), snapshot, MODE)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'subagent.expected.md'), snapshot, MODE)
+    const controlHeight = await depth.evaluate(element => element.getBoundingClientRect().height)
     await depth.fill('1.5')
+    expect(await depth.evaluate(element => element.getBoundingClientRect().height)).toBe(controlHeight)
     expect(await dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
     await depth.fill('2')
     await dialog.getByRole('button', { name: '恢复默认', exact: true }).first().click()
     await dialog.getByRole('button', { name: '恢复默认', exact: true }).first().click()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    await dialog.getByRole('button', { name: '展开设置: Subagent 限制', exact: true }).waitFor()
-    await dialog.getByRole('button', { name: '展开设置: Subagent 限制', exact: true }).click()
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).waitFor()
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).click()
     expect(await depth.inputValue()).toBe('3')
     expect(await capacity.inputValue()).toBe('8')
-    await dialog.getByRole('button', { name: '收起设置: Subagent 限制', exact: true }).click()
+    await dialog.getByRole('button', { name: '收起设置: Subagent', exact: true }).click()
   })
 
-  it('persists selected adapter routes as the subagent model allowlist', async () => {
+  it('opens field explanations with the keyboard and retains unsaved edits', async () => {
+    const dialog = await openPlugins()
+    await dialog.getByRole('button', { name: '展开设置: Subagent', exact: true }).click()
+    const depth = dialog.getByLabel('最大递归深度', { exact: true })
+    await depth.fill('2')
+    const depthHelp = dialog.getByRole('button', { name: '最大递归深度说明', exact: true })
+    expect(await dialog.getByRole('region', { name: '最大递归深度说明', exact: true }).count()).toBe(0)
+    await depthHelp.press('Enter')
+    const depthRules = dialog.getByRole('region', { name: '最大递归深度说明', exact: true })
+    await depthRules.waitFor()
+    expect(await depthRules.getByText('限制 Agent 创建 Subagent 的递归层级。', { exact: true }).count()).toBe(1)
+    const depthTable = depthRules.getByRole('table', { name: '最大递归深度说明', exact: true })
+    expect(await depthTable.getByRole('row', { name: '0 禁用 Subagent', exact: true }).count()).toBe(1)
+    expect(await depthTable.getByRole('row', { name: '1 仅允许主 Agent 创建 Subagent', exact: true }).count()).toBe(1)
+    expect(await depthRules.getByText('如果某个工具单独设置了最大递归深度,以该工具的设置为准。', { exact: true }).count()).toBe(1)
+    await depthHelp.press('Enter')
+    expect(await depthRules.count()).toBe(0)
+    expect(await depth.inputValue()).toBe('2')
+    await dialog.getByRole('button', { name: 'Subagent 总数上限说明', exact: true }).click()
+    const capacityRules = dialog.getByRole('region', { name: 'Subagent 总数上限说明', exact: true })
+    expect(await capacityRules.getByText('同一主 Agent 下,同时存活的 Subagent 总数,包含所有递归层级。等待中的 Subagent 也计入,主 Agent 不计入。', { exact: true }).count()).toBe(1)
+    await dialog.getByRole('button', { name: '放弃修改', exact: true }).click()
+    expect(await depth.inputValue()).toBe('3')
+  })
+
+  it('saves limits and the model allowlist together from the shared card', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
     const dialog = await openPlugins()
-    await dialog.getByText('Subagent 模型', { exact: true }).click()
+    await dialog.getByText('Subagent', { exact: true }).click()
     const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
 
+    await dialog.getByLabel('最大递归深度', { exact: true }).fill('2')
     await toggle.click()
     const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
     await models.waitFor({ timeout: 10_000 })
@@ -129,10 +157,11 @@ describe('web e2e: plugin configuration section', () => {
     await firstModel.check()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
 
-    const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent 模型' })
+    const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' })
     await expandSubagent.waitFor({ timeout: 5_000 })
     await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
       .toBe(true)
+    expect(await settingsDocument()).toContain('maxDepth: 2')
     expect(await settingsDocument()).toContain('enabled: true')
     expect(await settingsDocument()).toContain('allowedModels:')
     expect(await settingsDocument()).toContain('provider:')
@@ -143,6 +172,7 @@ describe('web e2e: plugin configuration section', () => {
     expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
 
     await toggle.click()
+    await dialog.getByRole('button', { name: '恢复默认', exact: true }).click()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await expandSubagent.waitFor({ timeout: 5_000 })
     await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
@@ -247,6 +277,6 @@ describe('web e2e: plugin configuration section', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md', 'subagent-limits.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md', 'subagent.expected.md'])
   })
 })

+ 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: 7bc8299fa17cb0faf34a020344f61fc5665ff1af
-README.zh.md: 5ed8966176e011dc53c5d5d9bdc4c216b100807c
+README.md: 753819b9bdcba692d31f6f8d0f471b5816c18391
+README.zh.md: a5b9f3b7434e46ca3bde81863ac044b357deac45

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

@@ -25,7 +25,7 @@ Use the **Plugins** settings section to configure the plugins exposed by the cur
 <a id="use-this-package"></a>
 ## Use this package
 
-Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent limits (`subagent`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
+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 delegation limits and model selection (`subagent` and `subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
 
 ### What appears here
 
@@ -35,9 +35,11 @@ The tab reads which settings namespaces the Host serves and dispatches one slot
 
 A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted.
 
-The **Subagent limits** card presents depth and online capacity side by side, stacking on narrow screens. It validates whole-number drafts, preserves explicit tool depth overrides, and explains that capacity includes waiting continuable children while excluding the root, one-shot runs, and external providers. Saving applies to later delegation attempts; lowering capacity does not stop existing children.
+The **Subagent** card groups delegation limits and model selection under one disclosure and one save/discard footer. **Maximum recursion depth** and **Subagent count limit** appear side by side, stacking on narrow screens. Information buttons reveal a two-row depth example and the shared count rule; validation errors remain visible below the input. Depth retains explicit tool overrides. Capacity counts live continuable descendants across all recursion levels, including waiting children and excluding the root, one-shot runs, and external providers. Saving applies to later delegation attempts; lowering capacity does not stop existing children.
 
-The Subagent models card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects.
+The model selection section stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects.
+
+Saving the Subagent card validates both sections and writes their drafts through their existing namespaces. These writes are independent: if one fails, the card stays open with that draft retained, and retry writes only the remaining draft. The model-only registration renders the same card when the deployment serves model selection without limits.
 
 ### Secret-role fields
 

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

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent loop(智能体循环)的工具调用并行度(`agent-loop`)、subagent 限制(`subagent`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
+打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent loop(智能体循环)的工具调用并行度(`agent-loop`)、Subagent 委派限制与模型选择(`subagent` 和 `subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
 
 ### 这里会出现什么
 
@@ -35,9 +35,11 @@ kind: "package-reference"
 
 卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。
 
-**Subagent 限制**卡片并排呈现深度和在线数量,窄屏时上下排列。它校验整数草稿,保留工具显式深度覆盖,并说明在线数量计入等待中的可续接子代理,不计入根代理、一次性运行和外部提供方。保存后用于后续委派;调低在线上限不会停止已有子代理。
+**Subagent** 卡片将委派限制与模型选择放在同一个折叠项中,共用保存和放弃修改操作。**最大递归深度**和 **Subagent 总数上限**并排呈现,窄屏时上下排列。信息按钮按需展开两行深度示例表和共享计数规则;校验错误仍直接显示在输入框下。深度保留工具显式覆盖。总数统计所有递归层级中存活的可续接 Subagent,包含等待中的 Subagent,不计入主 Agent、一次性运行和外部提供方。保存后用于后续委派;调低上限不会停止已有 Subagent。
 
-Subagent 模型卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。
+模型选择分区会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。
+
+保存 Subagent 卡片时会校验两个分区,并通过各自原有命名空间写入草稿。这些写入相互独立:若其中一项失败,卡片保持展开并保留该草稿,重试仅写入剩余草稿。若部署只提供模型选择而不提供限制设置,模型命名空间的注册项会呈现同一张卡片。
 
 ### secret 角色字段
 

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

@@ -106,7 +106,7 @@
   margin: 0;
   font-size: 12px;
   line-height: 1.5;
-  color: var(--dsw-alias-label-error);
+  color: var(--dsw-alias-state-error-primary);
 }
 
 .discard,

+ 16 - 0
packages/client/ui-settings-plugins/src/client/SubagentCard.module.css

@@ -0,0 +1,16 @@
+.section {
+  min-width: 0;
+  padding: 16px 0;
+}
+
+.section + .section {
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+.heading {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.5;
+  color: var(--dsw-alias-label-primary);
+}

+ 51 - 0
packages/client/ui-settings-plugins/src/client/SubagentCard.tsx

@@ -0,0 +1,51 @@
+/** One settings card for Subagent delegation limits and model authorization. */
+
+import { useId } from 'react'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from './slot-contract.ts'
+import { PluginCard } from './PluginCard.tsx'
+import { SubagentLimitsFields } from './SubagentLimitsFields.tsx'
+import { SubagentModelSelectionFields } from './SubagentModelSelectionFields.tsx'
+import { subagentCardShell, type SubagentCardFace } from './subagent-card-controller.ts'
+import css from './SubagentCard.module.css'
+
+/** Framework-derived props for the shared Subagent settings card. */
+export type SubagentCardProps = PropsRuntime<'settings.plugin.item'>
+  & PropsLocale<'settings.plugins'> & InjectFace<SubagentCardFace>
+
+/**
+ * Render the available Subagent settings with one disclosure and save footer.
+ * @param props - Locale, both form snapshots, and their shared actions.
+ * @returns One card; the fallback stays hidden when the primary namespace is available.
+ */
+export function SubagentCard(props: SubagentCardProps) {
+  const { t } = props
+  const limits = props.useSubagentLimitsCard(snapshot => snapshot)
+  const models = props.useSubagentModelSelectionCard(snapshot => snapshot)
+  const headingId = useId()
+  if (props.fallback && limits.available) return null
+  const state = subagentCardShell(limits, models)
+  return (
+    <PluginCard t={t} titleKey="subagentTitle" descriptionKey="subagentDescription"
+      state={state} onSave={props.save} onDiscard={props.discard}>
+      {limits.available
+        ? (
+          <section className={css.section} aria-labelledby={`${headingId}-limits`}>
+            <h3 className={css.heading} id={`${headingId}-limits`}>{t('subagentLimitsTitle')}</h3>
+            <SubagentLimitsFields t={t} state={{ ...limits, saving: state.saving }}
+              edit={props.editLimit} resetField={props.resetLimit} />
+          </section>
+        )
+        : null}
+      {models.available
+        ? (
+          <section className={css.section} aria-labelledby={`${headingId}-models`}>
+            <h3 className={css.heading} id={`${headingId}-models`}>{t('subagentModelSelectionTitle')}</h3>
+            <SubagentModelSelectionFields t={t} state={{ ...models, saving: state.saving }}
+              toggleEnabled={props.toggleEnabled} toggleModel={props.toggleModel} retryCatalog={props.retryCatalog} />
+          </section>
+        )
+        : null}
+    </PluginCard>
+  )
+}

+ 0 - 53
packages/client/ui-settings-plugins/src/client/SubagentLimitsCard.module.css

@@ -1,53 +0,0 @@
-.limits {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(min(230px, 100%), 1fr));
-  gap: 12px;
-  margin: 8px 0 12px;
-}
-
-.limit {
-  min-width: 0;
-  padding: 16px;
-  border: 0.5px solid var(--dsw-alias-border-l3);
-  border-radius: 12px;
-  background: var(--dsw-alias-bg-layer-2);
-}
-
-.illustration {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 16px;
-  color: var(--dsw-alias-brand-primary);
-  font-size: 11px;
-  line-height: 1.5;
-}
-
-.illustration svg {
-  width: 96px;
-  height: 36px;
-  flex-shrink: 0;
-  overflow: visible;
-  fill: var(--dsw-alias-bg-layer-2);
-  stroke: currentColor;
-  stroke-width: 1.5;
-}
-
-.illustration span {
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.limit input {
-  font-size: 24px;
-  line-height: 1.4;
-  font-variant-numeric: tabular-nums;
-  height: 46px;
-}
-
-.note {
-  margin: 0;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: 12px;
-  line-height: 1.6;
-}

+ 0 - 63
packages/client/ui-settings-plugins/src/client/SubagentLimitsCard.tsx

@@ -1,63 +0,0 @@
-/** Controls for delegation depth and the live continuable-child limit. */
-
-import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import { PluginCard } from './PluginCard.tsx'
-import { ValueField } from './fields.tsx'
-import type { SubagentLimitsCardFace } from './subagent-limits-card-controller.ts'
-import type {} from './slot-contract.ts'
-import css from './SubagentLimitsCard.module.css'
-
-/** Framework-derived inputs for the delegation limits card. */
-export type SubagentLimitsCardProps = PropsRuntime<'settings.plugin.item'>
-  & PropsLocale<'settings.plugins'> & InjectFace<SubagentLimitsCardFace>
-
-/**
- * Render separately explained depth and capacity controls with staged saving.
- * @param props - Locale, limit values, and settings actions.
- * @returns The limits card when the Host serves its settings section.
- */
-export function SubagentLimitsCard(props: SubagentLimitsCardProps) {
-  const { t } = props
-  const state = props.useSubagentLimitsCard(value => value)
-  return (
-    <PluginCard t={t} titleKey="subagentLimitsTitle" descriptionKey="subagentLimitsDescription"
-      state={state} onSave={props.save} onDiscard={props.discard}>
-      <div className={css.limits}>
-        <div className={css.limit}>
-          <div className={css.illustration} aria-hidden="true">
-            <svg viewBox="0 0 120 40" focusable="false">
-              <path d="M24 10h12v12h8m16 0h12v12h8" />
-              <rect x="8" y="4" width="16" height="12" rx="3" />
-              <rect x="44" y="16" width="16" height="12" rx="3" />
-              <rect x="80" y="28" width="16" height="12" rx="3" />
-            </svg>
-            <span>{t('subagentDepthEyebrow')}</span>
-          </div>
-          <ValueField id="plugin-config-subagent-depth" label={t('subagentMaxDepth')}
-            hint={t('subagentMaxDepthHint')} overriddenLabel={t('overridden')}
-            resetLabel={t('reset')} invalidLabel={t('subagentDepthInvalid')}
-            numeric disabled={!state.writable || state.saving} {...state.maxDepth}
-            onEdit={(text) => { props.edit('maxDepth', text) }} onReset={() => { props.resetField('maxDepth') }} />
-        </div>
-        <div className={css.limit}>
-          <div className={css.illustration} aria-hidden="true">
-            <svg viewBox="0 0 120 40" focusable="false">
-              <path d="M52 12v10M16 26v-6h72v6" />
-              <rect x="44" y="2" width="16" height="12" rx="3" />
-              <rect x="8" y="26" width="16" height="12" rx="3" />
-              <rect x="44" y="26" width="16" height="12" rx="3" />
-              <rect x="80" y="26" width="16" height="12" rx="3" />
-            </svg>
-            <span>{t('subagentCapacityEyebrow')}</span>
-          </div>
-          <ValueField id="plugin-config-subagent-capacity" label={t('subagentMaxActive')}
-            hint={t('subagentMaxActiveHint')} overriddenLabel={t('overridden')}
-            resetLabel={t('reset')} invalidLabel={t('subagentCapacityInvalid')}
-            numeric disabled={!state.writable || state.saving} {...state.maxActiveSubagents}
-            onEdit={(text) => { props.edit('maxActiveSubagents', text) }} onReset={() => { props.resetField('maxActiveSubagents') }} />
-        </div>
-      </div>
-      <p className={css.note}>{t('subagentLimitsApplies')}</p>
-    </PluginCard>
-  )
-}

+ 41 - 0
packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css

@@ -0,0 +1,41 @@
+.limits {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
+  gap: 16px;
+}
+
+.limit {
+  min-width: 0;
+}
+
+.limit input {
+  min-width: 0;
+  font-variant-numeric: tabular-nums;
+}
+
+.depthTable {
+  width: 100%;
+  margin: 8px 0;
+  border-collapse: collapse;
+  border-block: 0.5px solid var(--dsw-alias-border-l2);
+  font: inherit;
+  text-align: left;
+}
+
+.depthTable th,
+.depthTable td {
+  padding: 6px 0;
+  vertical-align: top;
+}
+
+.depthTable th {
+  width: 24px;
+  padding-right: 8px;
+  font-weight: 500;
+  font-variant-numeric: tabular-nums;
+  color: var(--dsw-alias-label-primary);
+}
+
+.depthTable tr + tr {
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}

+ 57 - 0
packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx

@@ -0,0 +1,57 @@
+/** Delegation-limit fields inside the shared Subagent settings card. */
+
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { ValueField } from './fields.tsx'
+import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts'
+import css from './SubagentLimitsFields.module.css'
+
+/** Plain state and edit callbacks supplied by the owning card. */
+export type SubagentLimitsFieldsProps = PropsLocale<'settings.plugins'>
+  & Pick<SubagentLimitsCardFace, 'edit' | 'resetField'>
+  & { state: SubagentLimitsCardState }
+
+/**
+ * Render the depth and capacity fields with their original validation and reset behavior.
+ * @param props - Locale, staged fields, and edit callbacks.
+ * @returns Two responsive fields and their application rules.
+ */
+export function SubagentLimitsFields(props: SubagentLimitsFieldsProps) {
+  const { t, state } = props
+  return (
+    <>
+      <div className={css.limits}>
+        <div className={css.limit}>
+          <ValueField id="plugin-config-subagent-depth" label={t('subagentMaxDepth')}
+            help={{ label: t('subagentDepthHelpLabel'), content: (
+              <>
+                <p>{t('subagentDepthHelp')}</p>
+                <table className={css.depthTable} aria-label={t('subagentDepthHelpLabel')}>
+                  <tbody>
+                    <tr>
+                      <th scope="row">{0}</th>
+                      <td>{t('subagentDepthZero')}</td>
+                    </tr>
+                    <tr>
+                      <th scope="row">{1}</th>
+                      <td>{t('subagentDepthOne')}</td>
+                    </tr>
+                  </tbody>
+                </table>
+                <p>{t('subagentDepthOverride')}</p>
+              </>
+            ) }} overriddenLabel={t('overridden')}
+            resetLabel={t('reset')} invalidLabel={t('subagentDepthInvalid')}
+            numeric disabled={!state.writable || state.saving} {...state.maxDepth}
+            onEdit={(text) => { props.edit('maxDepth', text) }} onReset={() => { props.resetField('maxDepth') }} />
+        </div>
+        <div className={css.limit}>
+          <ValueField id="plugin-config-subagent-capacity" label={t('subagentMaxActive')}
+            help={{ label: t('subagentCapacityHelpLabel'), content: <p>{t('subagentCapacityHelp')}</p> }} overriddenLabel={t('overridden')}
+            resetLabel={t('reset')} invalidLabel={t('subagentCapacityInvalid')}
+            numeric disabled={!state.writable || state.saving} {...state.maxActiveSubagents}
+            onEdit={(text) => { props.edit('maxActiveSubagents', text) }} onReset={() => { props.resetField('maxActiveSubagents') }} />
+        </div>
+      </div>
+    </>
+  )
+}

+ 2 - 2
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css → packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css

@@ -40,7 +40,7 @@
 
 .invalid,
 .conflict {
-  color: var(--dsw-alias-label-error);
+  color: var(--dsw-alias-state-error-primary);
 }
 
 .catalogError {
@@ -49,7 +49,7 @@
   justify-content: space-between;
   gap: 12px;
   font-size: 12px;
-  color: var(--dsw-alias-label-error);
+  color: var(--dsw-alias-state-error-primary);
 }
 
 .catalogError button {

+ 12 - 22
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx → packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx

@@ -1,29 +1,26 @@
 /** User control for model-selectable subagent delegation in new sessions. */
 
 import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
   SubagentModelCandidate,
   SubagentModelSelectionCardFace,
+  SubagentModelSelectionCardState,
 } from './subagent-model-selection-card-controller.ts'
-import type {} from './slot-contract.ts'
-import { PluginCard } from './PluginCard.tsx'
-import css from './SubagentModelSelectionCard.module.css'
+import css from './SubagentModelSelectionFields.module.css'
 
-/** Props the renderer binds for the subagent model-selection card. */
-export type SubagentModelSelectionCardProps =
-  PropsRuntime<'settings.plugin.item'>
-  & PropsLocale<'settings.plugins'>
-  & InjectFace<SubagentModelSelectionCardFace>
+/** Plain model state and callbacks supplied by the owning Subagent card. */
+export type SubagentModelSelectionFieldsProps = PropsLocale<'settings.plugins'>
+  & Pick<SubagentModelSelectionCardFace, 'toggleEnabled' | 'toggleModel' | 'retryCatalog'>
+  & { state: SubagentModelSelectionCardState }
 
 /**
  * Render the default-off preference and its exact adapter-route choices.
  * @param props - locale copy, the card snapshot, and its toggle action.
- * @returns the preference card, or nothing when the namespace is unavailable.
+ * @returns the model permission and route choices inside the shared card.
  */
-export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) {
-  const { t } = props
-  const state = props.useSubagentModelSelectionCard(snapshot => snapshot)
+export function SubagentModelSelectionFields(props: SubagentModelSelectionFieldsProps) {
+  const { t, state } = props
   const availableGroups = new Map<string, {
     providerName: string
     candidates: SubagentModelCandidate[]
@@ -62,14 +59,7 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
     </label>
   )
   return (
-    <PluginCard
-      t={t}
-      titleKey="subagentModelSelectionTitle"
-      descriptionKey="subagentModelSelectionDescription"
-      state={state}
-      onSave={props.save}
-      onDiscard={props.discard}
-    >
+    <>
       <div className={css.permission}>
         <div className={css.toggleRow}>
           <span className={css.toggleLabel}>{t('subagentModelSelectionToggle')}</span>
@@ -133,6 +123,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
       {state.conflicted
         ? <p className={css.conflict} role="status">{t('subagentModelSelectionConflict')}</p>
         : null}
-    </PluginCard>
+    </>
   )
 }

+ 56 - 4
packages/client/ui-settings-plugins/src/client/fields.module.css

@@ -26,6 +26,59 @@
   color: var(--dsw-alias-label-primary);
 }
 
+.labelGroup {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  flex: 1;
+  min-width: 0;
+}
+
+.labelGroup > .label {
+  flex: 0 1 auto;
+}
+
+.helpButton {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  border: 0;
+  border-radius: 6px;
+  background: none;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+}
+
+.helpButton:hover,
+.helpButton[aria-expanded='true'] {
+  background: var(--dsw-alias-bg-layer-4);
+  color: var(--dsw-alias-label-primary);
+}
+
+.helpButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+.help {
+  padding: 10px 0 0;
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.help > p {
+  margin: 0;
+}
+
+.help > p + p {
+  margin-top: 8px;
+}
+
 .badges {
   display: inline-flex;
   align-items: center;
@@ -73,16 +126,15 @@
   cursor: default;
 }
 
-.inputInvalid {
-  composes: input;
-  border-color: var(--dsw-alias-label-error);
+.input[aria-invalid='true'] {
+  border-color: var(--dsw-alias-state-error-primary);
 }
 
 .invalid {
   margin: 0;
   font-size: 12px;
   line-height: 1.5;
-  color: var(--dsw-alias-label-error);
+  color: var(--dsw-alias-state-error-primary);
 }
 
 .hint {

+ 32 - 7
packages/client/ui-settings-plugins/src/client/fields.tsx

@@ -6,7 +6,8 @@
  * card's save is the single point where a draft becomes a document mutation.
  */
 
-import { Tag } from '@deepseek-ai/dsh-client-ui-primitives'
+import { useState, type ReactNode } from 'react'
+import { IconInfoOutline14, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './fields.module.css'
 
 /** What every field control needs regardless of its value type. */
@@ -44,16 +45,36 @@ export interface FieldProps {
  * @param props - the field's copy, its staged text, and the edit actions.
  * @returns the labelled control.
  */
-export function ValueField(props: FieldProps & {
+export function ValueField(props: Omit<FieldProps, 'hint'> & {
+  /** Optional explanation shown below the input. */
+  hint?: string
+  /** Rules disclosed by the information button beside the label. */
+  help?: { label: string; content: ReactNode }
   /** Hints a numeric keypad without narrowing what the control accepts. */
   numeric?: boolean
   /** Placeholder shown while the draft is empty. */
   placeholder?: string
 }) {
+  const [helpOpen, setHelpOpen] = useState(false)
+  const helpId = `${props.id}-help`
+  const messageId = `${props.id}-message`
+  const hasMessage = props.invalid || Boolean(props.hint)
+  const description = [hasMessage ? messageId : '', helpOpen ? helpId : ''].filter(Boolean).join(' ')
   return (
     <div className={css.field}>
       <div className={css.head}>
-        <label className={css.label} htmlFor={props.id}>{props.label}</label>
+        <div className={css.labelGroup}>
+          <label className={css.label} htmlFor={props.id}>{props.label}</label>
+          {props.help !== undefined
+            ? (
+              <button type="button" className={css.helpButton}
+                aria-label={props.help.label} aria-expanded={helpOpen} aria-controls={helpId}
+                onClick={() => { setHelpOpen(!helpOpen) }}>
+                <IconInfoOutline14 />
+              </button>
+            )
+            : null}
+        </div>
         {props.overridden
           ? (
             <span className={css.badges}>
@@ -72,18 +93,22 @@ export function ValueField(props: FieldProps & {
       </div>
       <input
         id={props.id}
-        className={props.invalid ? css.inputInvalid : css.input}
+        className={css.input}
         type="text"
         {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
         {...props.invalid ? { 'aria-invalid': true } : {}}
+        aria-describedby={description || undefined}
         value={props.text}
         placeholder={props.placeholder ?? ''}
         disabled={props.disabled}
         onChange={(event) => { props.onEdit(event.target.value) }}
       />
-      <p className={props.invalid ? css.invalid : css.hint}>
-        {props.invalid ? props.invalidLabel : props.hint}
-      </p>
+      {hasMessage
+        ? <p id={messageId} className={props.invalid ? css.invalid : css.hint}>{props.invalid ? props.invalidLabel : props.hint}</p>
+        : null}
+      {props.help !== undefined && helpOpen
+        ? <div id={helpId} className={css.help} role="region" aria-label={props.help.label}>{props.help.content}</div>
+        : null}
     </div>
   )
 }

+ 11 - 8
packages/client/ui-settings-plugins/src/client/index.ts

@@ -5,8 +5,9 @@
  * 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.
+ * deployment already exposes. Each form binds its namespace through the client
+ * settings scope; the Subagent card composes its limit and model forms without
+ * adding domain-specific dispatch to the tab.
  */
 
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -25,9 +26,9 @@ import { BashCard } from './BashCard.tsx'
 import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
 import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
 import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
-import { SubagentLimitsCard } from './SubagentLimitsCard.tsx'
+import { SubagentCard } from './SubagentCard.tsx'
+import { subagentCardFace } from './subagent-card-controller.ts'
 import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts'
-import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
 import { WebSearchCard } from './WebSearchCard.tsx'
 import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
 import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
@@ -76,6 +77,8 @@ export function apply(ctx: ClientContext): void {
     ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
     ctx,
   )
+  const subagentLimitsFace = subagentLimits.inject()
+  const subagentModelsFace = subagentModelSelection.inject()
 
   // 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
@@ -184,14 +187,14 @@ export function apply(ctx: ClientContext): void {
       name: 'settings.plugin.item',
       key: 'subagent',
       locale: NS,
-      inject: () => subagentLimits.inject(),
-    }, SubagentLimitsCard)
+      inject: () => subagentCardFace(subagentLimitsFace, subagentModelsFace),
+    }, SubagentCard)
     yield ctx.slots.register({
       name: 'settings.plugin.item',
       key: SUBAGENT_MODEL_SELECTION_NS,
       locale: NS,
-      inject: () => subagentModelSelection.inject(),
-    }, SubagentModelSelectionCard)
+      inject: () => subagentCardFace(subagentLimitsFace, subagentModelsFace, true),
+    }, SubagentCard)
     yield ctx.slots.register({
       name: 'settings.plugin.item',
       key: WEB_SEARCH_NS,

+ 33 - 32
packages/client/ui-settings-plugins/src/client/locales.ts

@@ -11,18 +11,15 @@ export type PluginsSettingsLocaleKey =
   | 'webSearchTitle' | 'webSearchDescription'
   | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
   | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
-  | 'subagentLimitsTitle'
-  | 'subagentLimitsDescription'
+  | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle'
   | 'subagentMaxDepth'
-  | 'subagentMaxDepthHint'
+  | 'subagentDepthHelpLabel' | 'subagentDepthHelp'
+  | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride'
   | 'subagentMaxActive'
-  | 'subagentMaxActiveHint'
-  | 'subagentDepthEyebrow'
-  | 'subagentCapacityEyebrow'
+  | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp'
   | 'subagentDepthInvalid'
   | 'subagentCapacityInvalid'
-  | 'subagentLimitsApplies'
-  | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
+  | 'subagentModelSelectionTitle'
   | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed'
   | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry'
   | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
@@ -68,21 +65,23 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
   webSearchBaseUrlHint: 'Leave blank to use the provider default.',
   webSearchMaxUses: 'Max searches per request',
   webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
-  subagentLimitsTitle: 'Subagent limits',
-  subagentLimitsDescription: 'Set how deep delegation can go and how many subagents can stay online.',
-  subagentMaxDepth: 'Maximum depth',
-  subagentMaxDepthHint: 'The main agent is depth 0. Set 1 for direct children only, or 0 to disable delegation. Explicit tool limits take precedence.',
-  subagentMaxActive: 'Online limit',
-  subagentMaxActiveHint: 'Shared by all continuable descendants of one main agent. Waiting subagents also count; the main agent does not.',
-  subagentDepthEyebrow: 'Delegation depth',
-  subagentCapacityEyebrow: 'Shared capacity',
+  subagentTitle: 'Subagent',
+  subagentDescription: 'Set Subagent recursion depth, count, and models.',
+  subagentLimitsTitle: 'Limits',
+  subagentMaxDepth: 'Maximum recursion depth',
+  subagentDepthHelpLabel: 'About maximum recursion depth',
+  subagentDepthHelp: 'Limits how many levels of Subagents an Agent can create.',
+  subagentDepthZero: 'Disable Subagents',
+  subagentDepthOne: 'Only the main Agent can create Subagents',
+  subagentDepthOverride: 'If a tool defines its own maximum recursion depth, that setting takes precedence.',
+  subagentMaxActive: 'Subagent count limit',
+  subagentCapacityHelpLabel: 'About the Subagent count limit',
+  subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. Waiting Subagents count; the main Agent does not.',
   subagentDepthInvalid: 'Enter a whole number of 0 or more.',
   subagentCapacityInvalid: 'Enter a whole number of 1 or more.',
-  subagentLimitsApplies: 'Saved limits apply to future delegation attempts. Lowering capacity keeps existing subagents online. One-shot and external runs do not count toward capacity.',
-  subagentModelSelectionTitle: 'Subagent models',
-  subagentModelSelectionDescription: 'Control which models agents may choose for subagents.',
-  subagentModelSelectionToggle: 'Allow agents to choose models for subagents',
-  subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.',
+  subagentModelSelectionTitle: 'Model selection',
+  subagentModelSelectionToggle: 'Allow agents to choose models for Subagents',
+  subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each Subagent from the authorized models below. Applies only to new sessions.',
   subagentModelSelectionAllowed: 'Models agents may choose',
   subagentModelSelectionLoading: 'Loading models…',
   subagentModelSelectionLoadFailed: 'Models could not be loaded.',
@@ -135,19 +134,21 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   webSearchBaseUrlHint: '留空则使用提供方默认地址。',
   webSearchMaxUses: '单次请求最多搜索次数',
   webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
-  subagentLimitsTitle: 'Subagent 限制',
-  subagentLimitsDescription: '设置委派的最大深度,以及可同时在线的子代理数量。',
-  subagentMaxDepth: '最大深度',
-  subagentMaxDepthHint: '主代理的深度为 0。设为 1 时仅允许直接子代理;设为 0 时禁用委派。工具显式配置的深度优先。',
-  subagentMaxActive: '同时在线上限',
-  subagentMaxActiveHint: '同一主代理的所有可续接后代共享额度。等待中的子代理也计入;主代理不计入。',
-  subagentDepthEyebrow: '委派层级',
-  subagentCapacityEyebrow: '共享额度',
+  subagentTitle: 'Subagent',
+  subagentDescription: '设置 Subagent 的递归层级、数量和模型。',
+  subagentLimitsTitle: '运行限制',
+  subagentMaxDepth: '最大递归深度',
+  subagentDepthHelpLabel: '最大递归深度说明',
+  subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。',
+  subagentDepthZero: '禁用 Subagent',
+  subagentDepthOne: '仅允许主 Agent 创建 Subagent',
+  subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。',
+  subagentMaxActive: 'Subagent 总数上限',
+  subagentCapacityHelpLabel: 'Subagent 总数上限说明',
+  subagentCapacityHelp: '同一主 Agent 下,同时存活的 Subagent 总数,包含所有递归层级。等待中的 Subagent 也计入,主 Agent 不计入。',
   subagentDepthInvalid: '请输入不小于 0 的整数。',
   subagentCapacityInvalid: '请输入不小于 1 的整数。',
-  subagentLimitsApplies: '保存后用于后续委派。调低在线上限不会终止已有子代理。一次性运行和外部代理不占用在线额度。',
-  subagentModelSelectionTitle: 'Subagent 模型',
-  subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。',
+  subagentModelSelectionTitle: '模型选择',
   subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型',
   subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。',
   subagentModelSelectionAllowed: 'Agent 可选择的模型',

+ 84 - 0
packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts

@@ -0,0 +1,84 @@
+/** Shared presentation and actions for the two Host-owned Subagent settings sections. */
+
+import type { CardShell } from './card-form.ts'
+import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts'
+import type {
+  SubagentModelSelectionCardFace, SubagentModelSelectionCardState,
+} from './subagent-model-selection-card-controller.ts'
+
+/** Both existing form sources and the actions exposed by one Subagent card. */
+export interface SubagentCardFace {
+  hooks: SubagentLimitsCardFace['hooks'] & SubagentModelSelectionCardFace['hooks']
+  /** The model namespace supplies the card only when limits are not served. */
+  fallback: boolean
+  editLimit: SubagentLimitsCardFace['edit']
+  resetLimit: SubagentLimitsCardFace['resetField']
+  toggleEnabled: SubagentModelSelectionCardFace['toggleEnabled']
+  toggleModel: SubagentModelSelectionCardFace['toggleModel']
+  retryCatalog: SubagentModelSelectionCardFace['retryCatalog']
+  /** Save valid drafts through their owning namespace controllers. */
+  save: () => void
+  /** Discard both drafts without changing persisted settings. */
+  discard: () => void
+}
+
+/**
+ * Derive the shared card state without duplicating either form's subscriptions.
+ * @param limits - Current delegation-limit form.
+ * @param models - Current model-authorization form.
+ * @returns Availability and settlement across the sections this Host serves.
+ */
+export function subagentCardShell(
+  limits: SubagentLimitsCardState,
+  models: SubagentModelSelectionCardState,
+): CardShell {
+  const sections = [limits, models].filter(section => section.available)
+  return {
+    available: sections.length > 0,
+    writable: sections.every(section => section.writable),
+    dirty: sections.some(section => section.dirty),
+    invalid: sections.some(section => section.invalid)
+      || (models.available && models.dirty && models.conflicted),
+    saving: sections.some(section => section.saving),
+    failed: sections.some(section => section.failed),
+  }
+}
+
+/**
+ * Compose one card from the existing forms; each write retains its namespace revision fence.
+ * @param limits - Limit form source and actions.
+ * @param models - Model form source and actions.
+ * @param fallback - Whether this registration is the model-only fallback.
+ * @returns Framework-bound sources and shared save/discard actions.
+ */
+export function subagentCardFace(
+  limits: SubagentLimitsCardFace,
+  models: SubagentModelSelectionCardFace,
+  fallback = false,
+): SubagentCardFace {
+  return {
+    hooks: { ...limits.hooks, ...models.hooks },
+    fallback,
+    editLimit: limits.edit,
+    resetLimit: limits.resetField,
+    toggleEnabled: models.toggleEnabled,
+    toggleModel: models.toggleModel,
+    retryCatalog: models.retryCatalog,
+    save: () => {
+      const limitState = limits.hooks.subagentLimitsCard.getSnapshot()
+      const modelState = models.hooks.subagentModelSelectionCard.getSnapshot()
+      const state = subagentCardShell(limitState, modelState)
+      if (!state.available || !state.writable || !state.dirty || state.invalid || state.saving) return
+      if (modelState.available && modelState.dirty) models.save()
+      if (limitState.available && limitState.dirty) limits.save()
+    },
+    discard: () => {
+      if (subagentCardShell(
+        limits.hooks.subagentLimitsCard.getSnapshot(),
+        models.hooks.subagentModelSelectionCard.getSnapshot(),
+      ).saving) return
+      limits.discard()
+      models.discard()
+    },
+  }
+}

+ 6 - 3
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -93,7 +93,7 @@ describe('ui-settings-plugins apply', () => {
   })
 
 
-  it('injects a live tab projection, the card directory, and one business face per card', async () => {
+  it('injects a live tab projection, the card directory, and the form sources owned by each card', async () => {
     const { ctx, slots } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
@@ -120,8 +120,11 @@ describe('ui-settings-plugins apply', () => {
     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.
-      expect(Object.keys(face.hooks)).toHaveLength(1)
+      if (entry.options.key === 'subagent' || entry.options.key === 'subagent-model-selection') {
+        expect(Object.keys(face.hooks)).toEqual(['subagentLimitsCard', 'subagentModelSelectionCard'])
+      } else {
+        expect(Object.keys(face.hooks)).toHaveLength(1)
+      }
     }
   })
 

+ 141 - 32
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { SubagentLimitsCard, type SubagentLimitsCardProps } from '../src/client/SubagentLimitsCard.tsx'
+import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx'
 import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts'
 import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
 import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
@@ -14,8 +14,6 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx
 import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
 import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
 import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
-import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
-import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
 import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
 import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
 import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
@@ -88,17 +86,29 @@ function renderBash(state: Partial<BashCardState> = {}) {
   return renderBashCard(state).actions
 }
 
-function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
-  const store = createSnapshotStore<SubagentModelSelectionCardState>({
+function renderSubagent(
+  limitState: Partial<SubagentLimitsCardState> = {},
+  modelState: Partial<SubagentModelSelectionCardState> = {},
+  fallback = false,
+) {
+  const limits = createSnapshotStore<SubagentLimitsCardState>({
+    ...settled,
+    maxDepth: field('3'),
+    maxActiveSubagents: field('8'),
+    ...limitState,
+  })
+  const models = createSnapshotStore<SubagentModelSelectionCardState>({
     ...settled,
     enabled: false,
     candidates: [],
     catalogStatus: 'idle',
     catalogPartial: false,
     conflicted: false,
-    ...state,
+    ...modelState,
   })
   const actions = {
+    editLimit: vi.fn(),
+    resetLimit: vi.fn(),
     toggleEnabled: vi.fn(),
     toggleModel: vi.fn(),
     retryCatalog: vi.fn(),
@@ -108,10 +118,16 @@ function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardS
   const props = {
     ...actions,
     t,
-    useSubagentModelSelectionCard: bindSnapshotSelector(store),
-  } as unknown as SubagentModelSelectionCardProps
-  render(<SubagentModelSelectionCard {...props} />)
-  return actions
+    fallback,
+    useSubagentLimitsCard: bindSnapshotSelector(limits),
+    useSubagentModelSelectionCard: bindSnapshotSelector(models),
+  } as unknown as SubagentCardProps
+  render(<SubagentCard {...props} />)
+  return { actions, limits, models }
+}
+
+function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
+  return renderSubagent({ available: false }, state, true).actions
 }
 
 describe('PluginsSettingsSection', () => {
@@ -353,10 +369,10 @@ describe('BashCard', () => {
   })
 })
 
-describe('SubagentModelSelectionCard', () => {
+describe('Subagent model selection fields', () => {
   it('renders the default-off preference in its staged plugin card', () => {
     const actions = renderSubagentModelSelection()
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
 
     const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
     expect(control.getAttribute('aria-checked')).toBe('false')
@@ -390,7 +406,7 @@ describe('SubagentModelSelectionCard', () => {
       ],
       catalogStatus: 'ready',
     })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
 
     expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
     expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
@@ -402,13 +418,13 @@ describe('SubagentModelSelectionCard', () => {
 
   it('renders directory progress, failures, unavailable routes, and validation', () => {
     renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
     expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy()
     expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy()
 
     cleanup()
     const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
     fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry }))
     expect(errorActions.retryCatalog).toHaveBeenCalledOnce()
 
@@ -427,20 +443,20 @@ describe('SubagentModelSelectionCard', () => {
         selected: true,
       }],
     })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
     expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy()
     expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy()
     expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
 
     cleanup()
     renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
     expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy()
   })
 
   it('distinguishes a stale draft from a rejected save', () => {
     renderSubagentModelSelection({ dirty: true, conflicted: true })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
 
     expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy()
     expect(screen.queryByText(en.saveFailed)).toBeNull()
@@ -448,11 +464,11 @@ describe('SubagentModelSelectionCard', () => {
 
   it('stays hidden when unavailable and disables writes when read-only', () => {
     renderSubagentModelSelection({ available: false })
-    expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull()
+    expect(screen.queryByText(en.subagentTitle)).toBeNull()
 
     cleanup()
     const actions = renderSubagentModelSelection({ writable: false })
-    fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
+    fireEvent.click(screen.getByText(en.subagentTitle))
     const control = screen.getByRole('switch') as HTMLButtonElement
     expect(control.disabled).toBe(true)
     fireEvent.click(control)
@@ -573,21 +589,114 @@ describe('WebSearchCard', () => {
 })
 
 
-describe('SubagentLimitsCard', () => {
-  it('edits, resets and disables each limit while saving', () => {
-    const store = createSnapshotStore<SubagentLimitsCardState>({ ...settled, dirty: true, maxDepth: { ...field('3'), overridden: true }, maxActiveSubagents: { ...field('8'), overridden: true } })
-    const actions = cardActions()
-    const props = { ...actions, t, useSubagentLimitsCard: bindSnapshotSelector(store) } as unknown as SubagentLimitsCardProps
-    render(<SubagentLimitsCard {...props} />)
-    fireEvent.click(screen.getByText(en.subagentLimitsTitle))
+describe('SubagentCard', () => {
+  it('shows both sections under one disclosure and save footer', () => {
+    renderSubagent({ dirty: true })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+
+    expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy()
+    expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy()
+    expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1)
+    expect(screen.getAllByRole('button', { name: en.discard })).toHaveLength(1)
+  })
+
+  it('reveals field rules on demand without changing staged values', () => {
+    renderSubagent({ dirty: true, maxDepth: field('2') })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel })
+    const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel })
+    expect(depthHelp.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText(en.subagentDepthHelp)).toBeNull()
+    expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull()
+
+    fireEvent.click(depthHelp)
+    expect(depthHelp.getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy()
+    expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy()
+    expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy()
+    expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy()
+    expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy()
+    expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy()
+    fireEvent.click(capacityHelp)
+    expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy()
+    fireEvent.click(depthHelp)
+    expect(screen.queryByText(en.subagentDepthHelp)).toBeNull()
+    expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2')
+    expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false)
+  })
+
+  it('keeps validation visible when the rules are collapsed and links it to the input', () => {
+    renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    const depth = screen.getByLabelText(en.subagentMaxDepth)
+    const messageId = depth.getAttribute('aria-describedby')!
+    expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid)
+    expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull()
+  })
+
+  it('edits and resets limits through the shared card', () => {
+    const { actions, limits } = renderSubagent({
+      dirty: true,
+      maxDepth: field('3', { overridden: true }),
+      maxActiveSubagents: field('8', { overridden: true }),
+    })
+    fireEvent.click(screen.getByText(en.subagentTitle))
     fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } })
     fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } })
-    expect(actions.edit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']])
+    expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']])
     for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button)
-    expect(actions.resetField.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']])
-    act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
-    expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true)
-    act(() => { store.set({ ...store.getSnapshot(), saving: false, writable: false }) })
+    expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']])
+    act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) })
     expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true)
   })
+
+  it('blocks saving both sections when a model selection is invalid or conflicted', () => {
+    const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
+    act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) })
+    expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
+  })
+
+  it('locks both sections while either is saving and collapses after both settle', () => {
+    const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    act(() => {
+      limits.set({ ...limits.getSnapshot(), saving: true })
+      models.set({ ...models.getSnapshot(), saving: true })
+    })
+    expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true)
+    expect(screen.getByRole('switch')).toHaveProperty('disabled', true)
+    expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
+    act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) })
+    expect(screen.getByRole('switch')).toHaveProperty('disabled', true)
+    act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) })
+    expect(screen.queryByRole('switch')).toBeNull()
+  })
+
+  it('keeps a rejected section open after the other section saves', () => {
+    const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    act(() => {
+      limits.set({ ...limits.getSnapshot(), saving: true })
+      models.set({ ...models.getSnapshot(), saving: true })
+    })
+    act(() => {
+      limits.set({ ...limits.getSnapshot(), saving: false, dirty: false })
+      models.set({ ...models.getSnapshot(), saving: false, failed: true })
+    })
+    expect(screen.getByRole('switch')).toBeTruthy()
+    expect(screen.getByText(en.saveFailed)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false)
+  })
+
+  it('renders the model-only fallback only when the limits namespace is absent', () => {
+    renderSubagent({}, {}, true)
+    expect(screen.queryByText(en.subagentTitle)).toBeNull()
+    cleanup()
+    renderSubagent({ available: false }, {}, true)
+    fireEvent.click(screen.getByText(en.subagentTitle))
+    expect(screen.getByRole('switch')).toBeTruthy()
+    expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull()
+  })
 })

+ 104 - 0
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -8,6 +8,7 @@ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { CardForm, numberField, textField } from '../src/client/card-form.ts'
 import { SubagentLimitsCardController, type SubagentLimitsSettings } from '../src/client/subagent-limits-card-controller.ts'
+import { subagentCardFace, subagentCardShell } from '../src/client/subagent-card-controller.ts'
 import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
 import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
 import {
@@ -1189,3 +1190,106 @@ describe('SubagentLimitsCardController', () => {
     expect(host.scope.getSnapshot().value).toEqual({ maxDepth: 3, maxActiveSubagents: 8 })
   })
 })
+
+describe('shared Subagent card actions', () => {
+  function card() {
+    const limits = stubSettingsScope<SubagentLimitsSettings>()
+    const models = stubSettingsScope<SubagentModelSelectionSettings>()
+    const limitFace = new SubagentLimitsCardController(limits.scope).inject()
+    const modelFace = new SubagentModelSelectionCardController(models.scope, modelsApi().ctx).inject()
+    limits.publish({
+      status: 'ready', writable: true, revision: 2,
+      value: { maxDepth: 3, maxActiveSubagents: 8 },
+      base: { maxDepth: 3, maxActiveSubagents: 8 }, user: {},
+    })
+    models.publish({
+      status: 'ready', writable: true, revision: 5,
+      value: { enabled: false, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
+    })
+    acceptWrites(limits)
+    acceptWrites(models)
+    const face = subagentCardFace(limitFace, modelFace)
+    const state = () => subagentCardShell(
+      face.hooks.subagentLimitsCard.getSnapshot(),
+      face.hooks.subagentModelSelectionCard.getSnapshot(),
+    )
+    return { limits, models, face, state }
+  }
+
+  it('saves both drafts through their existing namespaces from one action', async () => {
+    const { limits, models, face, state } = card()
+    face.editLimit('maxDepth', '2')
+    face.toggleEnabled()
+    face.save()
+    await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
+    expect(limits.set).toHaveBeenCalledWith('maxDepth', 2)
+    expect(models.mutate).toHaveBeenCalledWith([
+      { op: 'set', path: ['enabled'], value: true },
+      { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
+    ], 5)
+  })
+
+  it('saves a limit-only draft without rewriting model authorization', async () => {
+    const { limits, models, face, state } = card()
+    face.editLimit('maxDepth', '2')
+    face.save()
+    await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
+    expect(limits.scope.getSnapshot().value?.maxDepth).toBe(2)
+    expect(models.mutate).not.toHaveBeenCalled()
+    expect(models.scope.getSnapshot().value?.enabled).toBe(false)
+  })
+
+  it('retains the pending draft when discard is requested before both writes finish', async () => {
+    const { limits, face, state } = card()
+    const pending = deferred<undefined>()
+    limits.set.mockImplementationOnce(async () => {
+      await pending.promise
+      limits.publish({ value: { maxDepth: 2, maxActiveSubagents: 8 }, user: { maxDepth: 2 } })
+    })
+    face.editLimit('maxDepth', '2')
+    face.toggleEnabled()
+    face.save()
+    try {
+      await vi.waitFor(() => {
+        expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ saving: false, dirty: false })
+      })
+      expect(state().saving).toBe(true)
+      expect(limits.set).toHaveBeenCalledWith('maxDepth', 2)
+      face.discard()
+      expect(face.hooks.subagentLimitsCard.getSnapshot()).toMatchObject({ dirty: true, maxDepth: { text: '2' } })
+    } finally {
+      pending.resolve(undefined)
+      await vi.waitFor(() => { expect(state().saving).toBe(false) })
+    }
+    expect(state()).toMatchObject({ dirty: false, failed: false })
+    expect(limits.scope.getSnapshot().value?.maxDepth).toBe(2)
+  })
+
+  it('writes neither namespace when either draft is invalid and discards both', () => {
+    const { limits, models, face, state } = card()
+    face.editLimit('maxDepth', '1.5')
+    face.toggleEnabled()
+    face.save()
+    expect(limits.set).not.toHaveBeenCalled()
+    expect(models.mutate).not.toHaveBeenCalled()
+    face.discard()
+    expect(state()).toMatchObject({ dirty: false, invalid: false })
+    expect(face.hooks.subagentLimitsCard.getSnapshot().maxDepth.text).toBe('3')
+    expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
+  })
+
+  it('retains a rejected model draft after limits save, and retries only that draft', async () => {
+    const { limits, models, face, state } = card()
+    models.mutate.mockImplementationOnce(() => {})
+    face.editLimit('maxDepth', '2')
+    face.toggleEnabled()
+    face.save()
+    await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: true, failed: true }) })
+    expect(face.hooks.subagentLimitsCard.getSnapshot().dirty).toBe(false)
+    expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: true, dirty: true })
+    face.save()
+    await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
+    expect(limits.set).toHaveBeenCalledOnce()
+    expect(models.mutate).toHaveBeenCalledTimes(2)
+  })
+})

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

@@ -1931,8 +1931,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-ui-settings-plugins BashCard',
       'client-ui-settings-plugins AgentLoopCard',
-      'client-ui-settings-plugins SubagentLimitsCard key \'subagent\'',
-      'client-ui-settings-plugins SubagentModelSelectionCard',
+      'client-ui-settings-plugins SubagentCard key \'subagent\'',
+      'client-ui-settings-plugins SubagentCard',
       'client-ui-settings-plugins WebSearchCard',
     ],
     replaceRisk: 'shadows-shipped-ui',

+ 2 - 2
packages/subagent/subagent/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/subagent/subagent/README.md
-README.md: 081967b5eba154832ed175d58339a23f2f5377f5
-README.zh.md: 5f4c87ad1ccf483cb695df088f62da7bcc183a2e
+README.md: 1d9aa0ea157dd156406b87f17d82673e7af85802
+README.zh.md: 59b99d2318f28d84f7778344b805155112a9cbd0

+ 1 - 1
packages/subagent/subagent/README.md

@@ -44,7 +44,7 @@ An agent that calls the tool gets the child's final answer as the tool result. M
 
 ### Delegation settings
 
-The **Subagent limits** card in **Settings → Plugins → Plugin configuration** edits the `subagent` settings section. User values override this plugin's composition; reset removes the user override. `maxDepth` defaults to `3` and supplies the delegation tools' depth when their own configuration omits it. An explicit tool depth, including `provider-managed`, takes precedence. Depth `0` disables delegation through tools inheriting this setting; depth `1` permits direct children only. Changes apply on the next delegation attempt. Direct service callers continue to supply their own optional request depth.
+The limits section of the **Subagent** card in **Settings → Plugins → Plugin configuration** edits the `subagent` settings section. User values override this plugin's composition; reset removes the user override. `maxDepth` defaults to `3` and supplies the delegation tools' depth when their own configuration omits it. An explicit tool depth, including `provider-managed`, takes precedence. Depth `0` disables delegation through tools inheriting this setting; depth `1` permits direct children only. Changes apply on the next delegation attempt. Direct service callers continue to supply their own optional request depth.
 
 ### Continuable capacity
 

+ 1 - 1
packages/subagent/subagent/README.zh.md

@@ -44,7 +44,7 @@ kind: "package-reference"
 
 ### 委派设置
 
-**设置 → 插件 → 插件配置**中的 **Subagent 限制**卡片编辑 `subagent` 设置分节。用户值覆盖本插件的组合配置;恢复默认会删除用户覆盖。`maxDepth` 默认为 `3`,在委派工具自身未配置深度时提供默认值。工具显式指定的深度(包括 `provider-managed`)优先。深度 `0` 禁止继承此设置的工具委派;深度 `1` 只允许直接子代理。修改在下一次委派时生效。直接调用服务的调用方仍自行提供可选的请求深度。
+**设置 → 插件 → 插件配置**中的 **Subagent** 卡片在「运行限制」分区编辑 `subagent` 设置分节。用户值覆盖本插件的组合配置;恢复默认会删除用户覆盖。`maxDepth` 默认为 `3`,在委派工具自身未配置深度时提供默认值。工具显式指定的深度(包括 `provider-managed`)优先。深度 `0` 禁止继承此设置的工具委派;深度 `1` 只允许直接子代理。修改在下一次委派时生效。直接调用服务的调用方仍自行提供可选的请求深度。
 
 ### 可续接子代理容量