Pārlūkot izejas kodu

Merge master and preserve desktop plugin manager removal

07akioni 2 nedēļas atpakaļ
vecāks
revīzija
3caff6dba4
100 mainītis faili ar 2275 papildinājumiem un 255 dzēšanām
  1. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  2. 3 3
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  3. 3 3
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
  5. 1 1
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
  6. 1 1
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md
  9. 1 1
      .agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml
  11. 29 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md
  12. 29 0
      .agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml
  14. 29 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
  15. 29 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md
  16. 2 2
      apps/desktop/README.i18n.yaml
  17. 5 5
      apps/desktop/README.md
  18. 5 5
      apps/desktop/README.zh.md
  19. 5 2
      apps/desktop/src/fatal-recovery.ts
  20. 2 0
      apps/desktop/src/ipc.ts
  21. 20 0
      apps/desktop/src/locale.ts
  22. 91 23
      apps/desktop/src/main.ts
  23. 2 0
      apps/desktop/src/preload-app.ts
  24. 112 0
      apps/desktop/src/preload-menu.ts
  25. 62 0
      apps/desktop/src/preload-windows.ts
  26. 4 0
      apps/desktop/src/windows-layout.ts
  27. 5 0
      apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap
  28. 5 0
      apps/desktop/tests/expected/fatal-address-in-use-en.txt
  29. 5 0
      apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt
  30. 29 0
      apps/desktop/tests/fatal-recovery.spec.ts
  31. 93 12
      apps/desktop/tests/main-startup.spec.ts
  32. 10 0
      apps/desktop/tests/preload-app.spec.ts
  33. 143 0
      apps/desktop/tests/preload-menu.client.spec.ts
  34. 55 0
      apps/desktop/tests/preload-windows.client.spec.ts
  35. 86 0
      apps/web/tests/context-meter.e2e.ts
  36. 1 1
      apps/web/tests/expected/cordis-history/ui.expected.md
  37. 1 1
      apps/web/tests/expected/plugin-config/official.expected.md
  38. 29 0
      apps/web/tests/expected/plugin-config/subagent.expected.md
  39. 1 1
      apps/web/tests/expected/plugin-manager/live-enabled.expected.md
  40. 1 1
      apps/web/tests/expected/plugin-manager/manager.expected.md
  41. 1 1
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  42. 1 1
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  43. 1 1
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  44. 1 1
      apps/web/tests/expected/steer-all/settled.expected.md
  45. 2 0
      apps/web/tests/fixtures/context-meter-no-chat.patch.yml
  46. 63 2
      apps/web/tests/plugin-config.e2e.ts
  47. 15 6
      apps/web/tests/reference-composer.e2e.ts
  48. 1 0
      apps/web/tsconfig.json
  49. 2 4
      packages/client/ui-chat/src/client/chat/StatsPills.module.css
  50. 1 3
      packages/client/ui-chat/src/client/chat/StatsPills.tsx
  51. 0 4
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  52. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  53. 2 0
      packages/client/ui-conversation/README.md
  54. 2 0
      packages/client/ui-conversation/README.zh.md
  55. 19 16
      packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css
  56. 25 15
      packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx
  57. 12 6
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  58. 6 4
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  59. 14 14
      packages/client/ui-conversation/tests/context-meter.client.spec.tsx
  60. 1 0
      packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
  61. 2 2
      packages/client/ui-layout/README.i18n.yaml
  62. 2 0
      packages/client/ui-layout/README.md
  63. 2 0
      packages/client/ui-layout/README.zh.md
  64. 32 0
      packages/client/ui-layout/src/client/AppFrame.module.css
  65. 5 4
      packages/client/ui-layout/src/client/AppFrame.tsx
  66. 13 0
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  67. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  68. 6 2
      packages/client/ui-settings-plugins/README.md
  69. 6 2
      packages/client/ui-settings-plugins/README.zh.md
  70. 12 0
      packages/client/ui-settings-plugins/src/client/SubagentCard.module.css
  71. 51 0
      packages/client/ui-settings-plugins/src/client/SubagentCard.tsx
  72. 41 0
      packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css
  73. 57 0
      packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx
  74. 2 2
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css
  75. 15 24
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx
  76. 56 4
      packages/client/ui-settings-plugins/src/client/fields.module.css
  77. 32 7
      packages/client/ui-settings-plugins/src/client/fields.tsx
  78. 24 18
      packages/client/ui-settings-plugins/src/client/index.ts
  79. 41 7
      packages/client/ui-settings-plugins/src/client/locales.ts
  80. 79 0
      packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts
  81. 61 0
      packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts
  82. 33 5
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  83. 143 13
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  84. 134 0
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  85. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  86. 2 0
      packages/client/ui-sidebar-right/README.md
  87. 2 0
      packages/client/ui-sidebar-right/README.zh.md
  88. 10 0
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css
  89. 2 2
      packages/client/ui-sidebar/README.i18n.yaml
  90. 2 0
      packages/client/ui-sidebar/README.md
  91. 2 0
      packages/client/ui-sidebar/README.zh.md
  92. 83 0
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  93. 5 4
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  94. 204 0
      packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap
  95. 15 0
      packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx
  96. 1 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  97. 2 2
      packages/subagent/subagent/README.i18n.yaml
  98. 1 1
      packages/subagent/subagent/README.md
  99. 1 1
      packages/subagent/subagent/README.zh.md
  100. 1 1
      snapshots/web/deepseek-messages-chat/ui.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.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/feature/2026-09-07-composer-session-stats-pills.md
-2026-09-07-composer-session-stats-pills.md: 541e5a363d78c4333b1b8e3927928a19754822fa
-2026-09-07-composer-session-stats-pills.zh.md: b6ef6da3c37a46d9e07e7ebdf77439e26896fddc
+2026-09-07-composer-session-stats-pills.md: 22f78dfdde854baaf1ced8c20c55042952df8c26
+2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c

+ 3 - 3
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md

@@ -13,9 +13,9 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
 `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
 
 - **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
-- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
+- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent.
 - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
-- **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
+- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding.
 
 ## Alternatives considered
 
@@ -27,5 +27,5 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
 
 - `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename.
 - Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings.
-- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
+- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin time, billing, and context order below the submit action, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
 - The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`.

+ 3 - 3
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md

@@ -13,9 +13,9 @@ Status: implemented
 `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
 
 - **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
-- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。
 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
-- **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
+- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。
 
 ## 备选方案
 
@@ -27,5 +27,5 @@ Status: implemented
 
 - `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
 - 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。
-- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
+- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住提交操作下方的时间、计费、上下文顺序,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
 - 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
-2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5
-2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343
+2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728
+2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc

+ 1 - 1
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md

@@ -12,7 +12,7 @@ The desktop app drew the stock macOS titlebar: an opaque bar above the web UI th
 
 The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur.
 
-Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before.
+Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). These rules do not apply to plain Web or other desktop platforms. The [Windows caption decision](2026-09-16-windows-desktop-titlebar.md) owns its separate presentation.
 
 **Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。
 
-所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致。
+所有 macOS Web 侧调整均以 `html[data-platform='darwin']` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。这些规则不适用于纯 Web 或其他桌面平台。[Windows 顶栏决策](2026-09-16-windows-desktop-titlebar.zh.md)负责其独立呈现。
 
 **透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.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/feature/2026-09-15-continuable-activation-capacity.md
-2026-09-15-continuable-activation-capacity.md: bf7ca6d78d7a39a9c5d42dbc000de98706863cb5
-2026-09-15-continuable-activation-capacity.zh.md: 404c2c0f4eef9576670b51cd253b53ad0d4ef0d5
+2026-09-15-continuable-activation-capacity.md: a6403d10fd4bcfb73147a0482293429e108939e2
+2026-09-15-continuable-activation-capacity.zh.md: 75cf6ca7a352d503cfffef29416c0990f98185db

+ 1 - 1
.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md

@@ -16,7 +16,7 @@ The Activation registry reserves a unique slot before fresh or cold-resume recon
 
 Pool lookup, admission and release take amortized constant time. A weak root map does not retain dead root Agents; each pool holds only occupied tokens. No Session catalog scan, tree traversal, durable counter, or public capacity-query API is added.
 
-The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown.
+The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. The GUI stages both numbers and resets each to composition through the existing revision-fenced settings path. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md

@@ -16,7 +16,7 @@ Activation registry 在新建或冷恢复重建首次让出执行前预占唯一
 
 池查找、接纳和释放的摊还时间复杂度均为常数。根代理的弱引用映射不会保留已结束的根 Agent;每个池只持有已占用的 token。不增加 Session 目录扫描、树遍历、持久计数器或公开的容量查询 API。
 
-Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。
+Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。GUI 暂存两个数值,并通过现有的 revision 校验设置路径分别恢复组合默认值。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。
 
 ## Alternatives considered
 

+ 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: d9457b3e9b9786d77dcced4cac8ca1086914699a
+2026-09-16-shared-subagent-settings-card.zh.md: ccde0c97df24239b4e5113fecbaec6e85f19b145

+ 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 page groups delegation limits and model selection, with one save button. Leaving the page discards both drafts. 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 stays open 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.
+
+The plugin registers one `plugins.item` entry while either namespace is served. Its component renders only the available sections, preserving limits-only and model-only deployments without teaching the Plugins page 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 on leaving, pending writes, partial failure and deployments serving either namespace. 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 页面组织委派限制与模型选择,共用一个保存按钮。离开页面会丢弃两个草稿。两个现有控制器仍各自拥有草稿、校验和命名空间写入。任一草稿无效或冲突时,卡片禁止保存;保存期间禁用两个分区;两者均成功完成后仍保持打开。失败的分区保留草稿以供重试。限制说明通过字段名旁的信息按钮按需展开,使用具体层级示例和计数规则。校验错误仍直接显示,避免折叠说明后隐藏阻止保存的原因。
+
+插件在任一命名空间可用时注册一个 `plugins.item` 条目。组件仅呈现可用的分区,既保留只提供限制或模型设置的部署,也无需让插件页了解 Subagent 分组。
+
+## Alternatives considered
+
+**保留分开的卡片。** 独立控件使委派策略与模型授权之间的关系不够清晰,也需要分别保存。
+
+**合并 Host 命名空间。** UI 分组不需要改变持久化设置、命名空间 revision 或两项策略不同的生效时机。现有命名空间控制器保留这些约定。
+
+## Consequences
+
+用户可以在同一处检查两个分区。保存仍分别写入各自的命名空间,因此可能部分成功;成功的草稿清除,被拒绝的草稿保持可见并可重试。模型授权仍在自己的命名空间内原子写入开关与路由。[包参考文档](../../../../packages/client/ui-settings-plugins/README.zh.md) 描述控件及其生效范围。
+
+## Verification
+
+聚焦的组件和控制器测试覆盖共同校验、保存、离开时丢弃草稿、进行中的写入、部分失败,以及仅提供任一命名空间的部署。组装后的 Web 场景通过一个按钮保存两个分区,并回读设置文档。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.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-windows-desktop-titlebar.md
+2026-09-16-windows-desktop-titlebar.md: 17fd2aac308cff0b4357a0c39c3fe757cfc60f5b
+2026-09-16-windows-desktop-titlebar.zh.md: 05903d86960942204d7b18d4155266c144effc1a

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md

@@ -0,0 +1,29 @@
+# Agent Note: Windows desktop caption and application menus
+
+Status: implemented
+
+English | [中文](2026-09-16-windows-desktop-titlebar.zh.md)
+
+## Problem
+
+Windows needs a compact caption that keeps sidebar navigation available when the sidebar is hidden or files fill the content area. A separate Application/Edit menu consumes another row and can show a different language from the application. The shared client must preserve ordinary Web and macOS presentation.
+
+## Decision
+
+The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame.
+
+Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
+
+## Alternatives considered
+
+An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and editing commands. Plugin management uses the main application’s Plugins page. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
+
+Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window.
+
+## Consequences
+
+The caption remains available above fullscreen file panels, and collapsed navigation consumes no vertical rail. Caption menus preserve application and editing commands while ordinary Web documents receive no desktop controls. Editing actions send the corresponding keys to the focused editor so its own undo history applies. Caption entries stay above content modal overlays. Keyboard activation restores the last editor and selection before dispatch; pointer activation preserves editor focus; menu completion clears the active entry. Main-process validation rejects foreign frames, unknown menu names, and invalid anchor coordinates. Native popup appearance follows the operating system.
+
+## Testing
+
+Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the absence of the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Windows 桌面顶栏与应用菜单
+
+Status: implemented
+
+[English](2026-09-16-windows-desktop-titlebar.md) | 中文
+
+## Problem
+
+Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保留侧栏导航。独立的应用/编辑菜单多占一行,且可能与应用语言不同。共享客户端必须保留普通 Web 和 macOS 的呈现。
+
+## Decision
+
+Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。
+
+Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
+
+## Alternatives considered
+
+按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
+
+自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。
+
+## Consequences
+
+顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。顶栏入口位于内容弹窗遮罩之上。键盘激活在派发前恢复上一编辑器及选区;指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。
+
+## Testing
+
+桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。

+ 2 - 2
apps/desktop/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 apps/desktop/README.md
-README.md: 42ceae1056acfaa747e576961b622e09cbfe42af
-README.zh.md: efd8c8914b11c379afad2204040677801ddf4326
+README.md: 227fcb57aa2093aaded0cdf6c9375e32bc4328fb
+README.zh.md: 88b5a07e1f7a8e3379cf725f37d2a01528196ba3

+ 5 - 5
apps/desktop/README.md

@@ -48,11 +48,11 @@ The application preload exposes boot readiness, fatal startup reporting, and nat
 
 The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus and native dialogs use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
 
-Electron's application menu provides update checks and Quit. Plugin management uses the main application's Plugins page. The native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
+Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
 
-On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Windows and Linux keep the application and Edit menus.
+On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
 
 ### Runtime and plugin activation
 
@@ -66,7 +66,7 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 
 The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files.
 
-Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. There is no startup timeout heuristic.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart.
 
 Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
@@ -221,7 +221,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
+A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually, including from the Windows caption's Application menu. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
 
 Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 

+ 5 - 5
apps/desktop/README.zh.md

@@ -48,11 +48,11 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单和原生对话框使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
 
-Electron 应用菜单提供检查更新和退出操作。插件管理使用主应用的“插件”页面。原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
+Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
 
-macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Windows 和 Linux 保留应用菜单和 Edit 菜单。
+macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
 
 ### 运行时与插件激活
 
@@ -66,7 +66,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
 
 [Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求不会触发恢复。不通过启动超时推断故障。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。
 
 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
@@ -221,7 +221,7 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
+打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项手动触发同一检查,Windows 从顶栏的“应用”菜单进入。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
 
 签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 

+ 5 - 2
apps/desktop/src/fatal-recovery.ts

@@ -43,12 +43,15 @@ export class DesktopFatalRecovery {
     let detail = desktopErrorState(error).message
     let message = messages.fatalSummary
     for (;;) {
+      const addressInUse = /\blisten EADDRINUSE\b/u.test(detail)
       const { response } = await this.operations.show({
         type: 'error',
         title: messages.startupFailed,
         message,
-        detail: dialogDetail(detail, messages),
-        buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
+        detail: addressInUse ? messages.startupAddressInUse : dialogDetail(detail, messages),
+        buttons: addressInUse
+          ? [messages.exitApplication, messages.restartApplication]
+          : [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
         defaultId: 1,
         cancelId: 0,
         noLink: true,

+ 2 - 0
apps/desktop/src/ipc.ts

@@ -8,6 +8,8 @@ export const DESKTOP_IPC = {
   bootFailed: 'dsh-desktop:boot-failed',
   directoryPick: 'dsh-desktop:directory-pick',
   nativeThemeSet: 'dsh-desktop:native-theme-set',
+  windowsAppearance: 'dsh-desktop:windows-appearance',
+  windowsMenu: 'dsh-desktop:windows-menu',
 } as const
 
 /** Scheme of Desktop-owned application documents. */

+ 20 - 0
apps/desktop/src/locale.ts

@@ -2,8 +2,18 @@
 
 export const en = {
   application: 'Application',
+  edit: 'Edit',
+  menuBar: 'Application menu',
+  delete: 'Delete',
+  undo: 'Undo',
+  redo: 'Redo',
+  cut: 'Cut',
+  copy: 'Copy',
+  paste: 'Paste',
+  selectAll: 'Select All',
   startupFailed: 'DeepSeek Harness is unavailable',
   fatalSummary: 'The application could not start or stopped unexpectedly.',
+  startupAddressInUse: 'Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.',
   diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.',
   startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
   exitApplication: 'Exit',
@@ -28,8 +38,18 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 
 export const zh = {
   application: '应用',
+  edit: '编辑',
+  menuBar: '应用菜单',
+  delete: '删除',
+  undo: '撤销',
+  redo: '重做',
+  cut: '剪切',
+  copy: '复制',
+  paste: '粘贴',
+  selectAll: '全选',
   startupFailed: 'DeepSeek Harness 无法使用',
   fatalSummary: '应用无法启动或已意外停止。',
+  startupAddressInUse: '有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。',
   diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。',
   startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
   exitApplication: '退出',

+ 91 - 23
apps/desktop/src/main.ts

@@ -1,3 +1,4 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
 /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
 
 import { writeFile } from 'node:fs/promises'
@@ -30,8 +31,13 @@ import { DesktopFatalRecovery } from './fatal-recovery.ts'
 let focusPrimaryWindow = (): void => {}
 let stopForRecovery = async (): Promise<void> => {}
 let shuttingDown = false
+let windowsLanguage: string | undefined
+
+function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
+  return resolveDesktopLocale(windowsLanguage ?? app.getLocale())
+}
 const recovery = new DesktopFatalRecovery({
-  messages: () => resolveDesktopLocale(app.getLocale()),
+  messages: () => currentDesktopLocale(),
   show: options => dialog.showMessageBox(options),
   stop: () => { shuttingDown = true; return stopForRecovery() },
   disablePlugins: async () => {
@@ -90,13 +96,18 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
   return port
 }
 
-function createWindow(preload: string, show = false): BrowserWindow {
+function createWindow(preload: string, show = false, primary = false): BrowserWindow {
   const window = new BrowserWindow({
     width: 1280,
     height: 840,
     minWidth: 880,
     minHeight: 600,
     show,
+    ...(process.platform === 'win32' && primary ? {
+      titleBarStyle: 'hidden' as const,
+      titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb',
+        symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' },
+    } : {}),
     // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy
     // needs a transparent window background to show through the page.
     ...(process.platform === 'darwin' ? {
@@ -137,7 +148,15 @@ function createWindow(preload: string, show = false): BrowserWindow {
       items.push({ role: 'copy', enabled: editFlags.canCopy })
     }
     // Empty accelerators suppress Electron's default shortcut labels for native roles.
-    if (items.length > 0) Menu.buildFromTemplate(items.map(item => ({ ...item, accelerator: '' }))).popup({ window })
+    if (items.length > 0) {
+      const messages = currentDesktopLocale()
+      Menu.buildFromTemplate(items.map(item => ({
+        ...item,
+        ...(process.platform === 'win32' && item.role !== undefined && item.role in messages
+          ? { label: messages[item.role as keyof typeof messages] } : {}),
+        accelerator: '',
+      }))).popup({ window })
+    }
   })
   window.webContents.on('will-navigate', (event, url) => {
     const destination = new URL(url)
@@ -161,7 +180,6 @@ async function main(): Promise<void> {
   let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
-  const messages = resolveDesktopLocale(app.getLocale())
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const applicationUrl = `${SCHEME}://app/`
   let hostUrl: string | undefined
@@ -283,8 +301,8 @@ async function main(): Promise<void> {
       if (manual) {
         await dialog.showMessageBox({
           type: 'error',
-          title: messages.updateCheckFailedTitle,
-          message: state.message ?? messages.unknownError,
+          title: currentDesktopLocale().updateCheckFailedTitle,
+          message: state.message ?? currentDesktopLocale().unknownError,
         })
       }
       return
@@ -293,18 +311,18 @@ async function main(): Promise<void> {
       if (manual) {
         await dialog.showMessageBox({
           type: 'info',
-          title: messages.updateCheckTitle,
-          message: state.message ?? messages.updateCurrent,
+          title: currentDesktopLocale().updateCheckTitle,
+          message: state.message ?? currentDesktopLocale().updateCurrent,
         })
       }
       return
     }
     const result = await dialog.showMessageBox({
       type: 'info',
-      title: messages.updateTitle,
-      message: messages.updateAvailable,
-      detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
-      buttons: [messages.installAndRestart, messages.later],
+      title: currentDesktopLocale().updateTitle,
+      message: currentDesktopLocale().updateAvailable,
+      detail: formatDesktopMessage(currentDesktopLocale().updateDetail, { version: state.version ?? '' }),
+      buttons: [currentDesktopLocale().installAndRestart, currentDesktopLocale().later],
       defaultId: 0,
       cancelId: 1,
     })
@@ -313,8 +331,8 @@ async function main(): Promise<void> {
     if (installed.phase === 'error') {
       await dialog.showMessageBox({
         type: 'error',
-        title: messages.updateFailedTitle,
-        message: installed.message ?? messages.unknownError,
+        title: currentDesktopLocale().updateFailedTitle,
+        message: installed.message ?? currentDesktopLocale().unknownError,
       })
     }
   }
@@ -328,18 +346,68 @@ async function main(): Promise<void> {
   const hideCommands: MenuItemConstructorOptions[] = darwin
     ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }]
     : []
-  Menu.setApplicationMenu(Menu.buildFromTemplate([{
-    label: darwin ? app.name : messages.application,
-    submenu: [
-      { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
-      { type: 'separator' },
-      ...hideCommands,
-      { role: 'quit' },
-    ],
+  const applicationItems = (): MenuItemConstructorOptions[] => [
+    { label: currentDesktopLocale().checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
+    { type: 'separator' },
+    ...hideCommands,
+    { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().exitApplication } : {}) },
+  ]
+  Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
+    label: process.platform === 'darwin' ? app.name : currentDesktopLocale().application,
+    submenu: applicationItems(),
   }, ...platformMenus]))
 
+  if (process.platform === 'win32') {
+    ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
+      assertDesktopSender(event, ['app'])
+      if (mainWindow === undefined || event.sender !== mainWindow.webContents
+        || event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error('desktop menu: rejected sender')
+      if ((name !== 'application' && name !== 'edit')
+        || typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)
+        || x < 0 || y < 0 || x > 100_000 || y > 100_000) throw new Error('desktop menu: invalid popup request')
+      const window = mainWindow
+      // Editor-owned history listens to key events rather than Chromium's native undo stack.
+      const editItem = (label: string, keyCode: string, modifiers: Array<'control'>, accelerator?: string): MenuItemConstructorOptions => ({
+        label,
+        ...(accelerator === undefined ? {} : { accelerator }),
+        click: () => {
+          window.webContents.focus()
+          window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
+          window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
+        },
+      })
+      const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
+        editItem(currentDesktopLocale().undo, 'Z', ['control'], 'Ctrl+Z'),
+        editItem(currentDesktopLocale().redo, 'Y', ['control'], 'Ctrl+Y'),
+        { type: 'separator' },
+        editItem(currentDesktopLocale().cut, 'X', ['control'], 'Ctrl+X'),
+        editItem(currentDesktopLocale().copy, 'C', ['control'], 'Ctrl+C'),
+        editItem(currentDesktopLocale().paste, 'V', ['control'], 'Ctrl+V'),
+        editItem(currentDesktopLocale().delete, 'Delete', []),
+        { type: 'separator' },
+        editItem(currentDesktopLocale().selectAll, 'A', ['control'], 'Ctrl+A'),
+      ]
+      const zoom = mainWindow.webContents.getZoomFactor()
+      return new Promise<void>((resolve) => {
+        Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve })
+      })
+    })
+    ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => {
+      if (mainWindow === undefined || event.sender !== mainWindow.webContents
+        || event.senderFrame !== mainWindow.webContents.mainFrame) return
+      if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return
+      if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) {
+        windowsLanguage = language
+      }
+      // Empty colors precede client stylesheet installation; only CSS color values cross IPC.
+      const validColor = (value: unknown): value is string => typeof value === 'string'
+        && /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value)
+      if (validColor(color) && validColor(symbolColor)) mainWindow.setTitleBarOverlay({ color, symbolColor })
+    })
+  }
+
   const createMainWindow = (): BrowserWindow => {
-    const window = createWindow(appPreload, true)
+    const window = createWindow(appPreload, true, true)
     mainWindow = window
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
     window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {

+ 2 - 0
apps/desktop/src/preload-app.ts

@@ -4,8 +4,10 @@ import { contextBridge, ipcRenderer } from 'electron'
 import { DESKTOP_IPC, SCHEME } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
+import { syncWindowsAppearance } from './preload-windows.ts'
 
 if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
+  syncWindowsAppearance()
   contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
     pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
   })

+ 112 - 0
apps/desktop/src/preload-menu.ts

@@ -0,0 +1,112 @@
+/** Windows caption menu labels and native popup anchors, isolated from the Web client. */
+import { ipcRenderer } from 'electron'
+import { DESKTOP_IPC } from './ipc.ts'
+import { resolveDesktopLocale } from './locale.ts'
+
+/**
+ * Mount the Windows caption menubar without moving focus out of the active editor.
+ * @returns Language refresh and document teardown operations.
+ */
+export function installWindowsMenu(): { update(): void; dispose(): void } {
+  const host = document.createElement('div')
+  host.dataset.windowsMenu = ''
+  const shadow = host.attachShadow({ mode: 'open' })
+  const style = document.createElement('style')
+  style.textContent = `
+    :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 1100;
+      height: var(--dsh-windows-titlebar-height); display: flex; align-items: center;
+      font-family: var(--dsw-font-family); -webkit-app-region: no-drag; }
+    [role=menubar] { display: flex; gap: 2px; }
+    button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px;
+      background: transparent; color: var(--dsw-alias-label-secondary);
+      font: inherit; font-size: 14px; cursor: default; }
+    button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover);
+      color: var(--dsw-alias-label-primary); }
+    button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; }
+  `
+  const bar = document.createElement('div')
+  bar.setAttribute('role', 'menubar')
+  let restoreEditor = (): void => {}
+  const rememberEditor = (event: FocusEvent): void => {
+    const target = event.composedPath()[0]
+    if (!(target instanceof HTMLElement) || target === host || shadow.contains(target)) return
+    if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)
+      && !target.matches('[contenteditable="true"]')) return
+    const selection = document.getSelection()
+    const ranges = selection === null ? [] : Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange())
+    const input = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : undefined
+    const start = input?.selectionStart
+    const end = input?.selectionEnd
+    const direction = input?.selectionDirection
+    restoreEditor = () => {
+      if (!target.isConnected) return
+      target.focus({ preventScroll: true })
+      if (input !== undefined && start != null && end != null) input.setSelectionRange(start, end, direction ?? undefined)
+      else if (selection !== null && ranges.length > 0) {
+        selection.removeAllRanges()
+        for (const range of ranges) selection.addRange(range)
+      }
+    }
+  }
+  document.addEventListener('focusout', rememberEditor, true)
+  const createButton = (name: 'application' | 'edit', index: 0 | 1): HTMLButtonElement => {
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.setAttribute('role', 'menuitem')
+    button.setAttribute('aria-haspopup', 'menu')
+    button.setAttribute('aria-expanded', 'false')
+    button.tabIndex = index === 0 ? 0 : -1
+    button.addEventListener('pointerdown', (event) => { event.preventDefault() })
+    button.addEventListener('mousedown', (event) => { event.preventDefault() })
+    const open = async (): Promise<void> => {
+      if (button.getAttribute('aria-expanded') === 'true') return
+      const rect = button.getBoundingClientRect()
+      button.setAttribute('aria-expanded', 'true')
+      if (document.activeElement === host) restoreEditor()
+      try { await ipcRenderer.invoke(DESKTOP_IPC.windowsMenu, name, rect.left, rect.bottom) }
+      catch (error) { console.error('Desktop caption menu failed', error) }
+      finally { button.setAttribute('aria-expanded', 'false') }
+    }
+    button.addEventListener('click', () => { void open() })
+    button.addEventListener('keydown', (event) => {
+      if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+        event.preventDefault()
+        const next = buttons[index === 0 ? 1 : 0]
+        button.tabIndex = -1
+        next.tabIndex = 0
+        next.focus()
+      } else if (event.key === 'ArrowDown') {
+        event.preventDefault()
+        void open()
+      }
+    })
+    bar.append(button)
+    return button
+  }
+  const buttons = [createButton('application', 0), createButton('edit', 1)] as const
+  shadow.append(style, bar)
+  const mount = (): void => {
+    // AppFrame owns this seat; boot readiness alone precedes the rendered application.
+    if (document.querySelector('[data-shell-overlay]') === null) return
+    document.body.append(host)
+    observer.disconnect()
+  }
+  const observer = new MutationObserver(mount)
+  observer.observe(document.body, { childList: true, subtree: true })
+  mount()
+  const update = (): void => {
+    const messages = resolveDesktopLocale(document.documentElement.lang)
+    bar.setAttribute('aria-label', messages.menuBar)
+    buttons[0].textContent = messages.application
+    buttons[1].textContent = messages.edit
+  }
+  update()
+  return {
+    update,
+    dispose: () => {
+      observer.disconnect()
+      document.removeEventListener('focusout', rememberEditor, true)
+      host.remove()
+    },
+  }
+}

+ 62 - 0
apps/desktop/src/preload-windows.ts

@@ -0,0 +1,62 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
+/** Synchronizes Windows context menus and caption colors with the application document. */
+import { ipcRenderer } from 'electron'
+import { DESKTOP_IPC } from './ipc.ts'
+import { installWindowsMenu } from './preload-menu.ts'
+
+/** Install the Windows-only titlebar marker and observe application language and palette changes. */
+export function syncWindowsAppearance(): void {
+  if (process.platform !== 'win32') return
+  const mark = (): void => {
+    const root = document.documentElement
+    root.dataset.windowsTitlebar = ''
+    root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`)
+  }
+  // The root can be absent before the HTML parser creates it.
+  if ((document.documentElement as HTMLElement | null) !== null) mark()
+  const install = (): void => {
+    mark()
+    const root = document.documentElement
+    const menu = installWindowsMenu()
+    const probe = document.createElement('span')
+    probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;background-color:var(--dsw-specific-sidebar-fill);color:var(--dsw-alias-label-primary)'
+    document.body.append(probe)
+    const canvas = document.createElement('canvas')
+    canvas.width = canvas.height = 1
+    const context = canvas.getContext('2d', { willReadFrequently: true })
+    if (context === null) throw new Error('Desktop caption requires a 2D canvas context')
+    const nativeColor = (color: string): string => {
+      context.clearRect(0, 0, 1, 1)
+      context.fillStyle = color
+      context.fillRect(0, 0, 1, 1)
+      const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data
+      return `rgba(${red}, ${green}, ${blue}, ${Number(alpha) / 255})`
+    }
+    let previous = ''
+    const send = (): void => {
+      const style = getComputedStyle(probe)
+      const color = nativeColor(style.backgroundColor)
+      const symbolColor = nativeColor(style.color)
+      const values = [root.lang, color, symbolColor]
+      const current = JSON.stringify(values)
+      if (current === previous) return
+      previous = current
+      menu.update()
+      ipcRenderer.send(DESKTOP_IPC.windowsAppearance, ...values)
+    }
+    const observer = new MutationObserver(send)
+    observer.observe(root, { attributes: true, attributeFilter: ['lang'] })
+    observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style'] })
+    observer.observe(document.head, { childList: true, subtree: true, characterData: true })
+    document.head.addEventListener('load', send, true)
+    window.addEventListener('pagehide', () => {
+      observer.disconnect()
+      menu.dispose()
+      probe.remove()
+      document.head.removeEventListener('load', send, true)
+    }, { once: true })
+    send()
+  }
+  if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', install, { once: true })
+  else install()
+}

+ 4 - 0
apps/desktop/src/windows-layout.ts

@@ -0,0 +1,4 @@
+/** Shared geometry for the Windows main-window caption. */
+
+/** Windows application titlebar height in device-independent pixels. */
+export const WINDOWS_TITLEBAR_HEIGHT = 40

+ 5 - 0
apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap

@@ -0,0 +1,5 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`localizes caption entries and removes the menu on disposal > chinese 1`] = `"<div role="menubar" aria-label="应用菜单"><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="0">应用</button><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="-1">编辑</button></div>"`;
+
+exports[`localizes caption entries and removes the menu on disposal > english 1`] = `"<div role="menubar" aria-label="Application menu"><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="0">Application</button><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="-1">Edit</button></div>"`;

+ 5 - 0
apps/desktop/tests/expected/fatal-address-in-use-en.txt

@@ -0,0 +1,5 @@
+DeepSeek Harness is unavailable
+The application could not start or stopped unexpectedly.
+Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.
+Exit
+Restart

+ 5 - 0
apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt

@@ -0,0 +1,5 @@
+DeepSeek Harness 无法使用
+应用无法启动或已意外停止。
+有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。
+退出
+重启

+ 29 - 0
apps/desktop/tests/fatal-recovery.spec.ts

@@ -19,6 +19,35 @@ function fixture(locale = 'en') {
 
 afterEach(() => { vi.restoreAllMocks() })
 
+it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict in %s', async (locale) => {
+  const { operations, choice, stopped, recovery } = fixture(locale)
+  const pending = recovery.report(new AggregateError([
+    new Error('webserver (@deepseek-ai/dsh-host-webserver): Error: listen EADDRINUSE: address already in use 127.0.0.1:19387'),
+  ], 'required startup failure'))
+  const options = operations.show.mock.calls[0]![0]
+  expect(options.buttons).toEqual([operations.messages().exitApplication, operations.messages().restartApplication])
+  await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n')
+    .toMatchFileSnapshot(`expected/fatal-address-in-use-${locale}.txt`)
+  choice.resolve({ response: 1, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+  expect(operations.restart).toHaveBeenCalledOnce()
+  expect(operations.disablePlugins).not.toHaveBeenCalled()
+})
+
+it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflict recovery on %s', async (platform) => {
+  vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+  const { operations, choice, stopped, recovery } = fixture()
+  const pending = recovery.report(new Error('listen EADDRINUSE: address already in use'))
+  expect(operations.show.mock.calls[0]![0].buttons).toEqual([
+    operations.messages().exitApplication, operations.messages().restartApplication,
+  ])
+  expect(operations.show.mock.calls[0]![0].detail).toBe(operations.messages().startupAddressInUse)
+  choice.resolve({ response: 0, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+})
+
 it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) => {
   const { operations, choice, stopped, recovery } = fixture(locale)
   const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed'))

+ 93 - 12
apps/desktop/tests/main-startup.spec.ts

@@ -1,7 +1,8 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
+import type { IpcMainInvokeEvent } from 'electron'
 import { join } from 'node:path'
-import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import type { MenuItemConstructorOptions, MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 import { en } from '../src/locale.ts'
 
@@ -35,11 +36,17 @@ const harness = await vi.hoisted(async () => {
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
       getURL: () => this.urls.at(-1) ?? '',
+      getZoomFactor: () => 1,
+      focus: vi.fn(),
+      sendInputEvent: vi.fn(),
       send: vi.fn(),
     })
     readonly show = vi.fn()
     readonly focus = vi.fn()
     readonly restore = vi.fn()
+    readonly setSize = vi.fn()
+    readonly setTitle = vi.fn()
+    readonly setTitleBarOverlay = vi.fn()
     constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) }
     isDestroyed() { return this.destroyed }
     isMinimized() { return false }
@@ -84,17 +91,15 @@ const harness = await vi.hoisted(async () => {
       if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve()
     }),
   })
-  const popup = vi.fn()
+  const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>()
   return {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
     failWindow(error: Error) { windowFailure = error },
     popup,
     protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Promise<Response>) => void>(),
     socketHeaders: vi.fn(),
-    menu: {
-      setApplicationMenu: vi.fn(),
-      buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })),
-    },
+    ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
+    menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })) },
     dialog: {
       showOpenDialog: vi.fn(),
       showErrorBox: vi.fn(),
@@ -130,7 +135,7 @@ vi.mock('electron', () => ({
   shell: { openExternal: harness.openExternal },
   nativeTheme: { themeSource: 'system' },
   ipcMain: {
-    on: vi.fn(),
+    on: harness.ipcOn,
     handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) },
   },
   Menu: harness.menu,
@@ -181,17 +186,19 @@ afterEach(async () => {
 })
 
 describe('desktop main startup', () => {
-  it('exposes application IPC and rejects the removed shell document', async () => {
+  it.each(['darwin', 'win32', 'linux'] as const)('exposes application IPC and rejects the removed shell document on %s', async (platform) => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
     await import('../src/main.ts')
     await harness.preparing.promise
     expect([...harness.handlers.keys()].sort()).toEqual([
       DESKTOP_IPC.boot, DESKTOP_IPC.bootFailed, DESKTOP_IPC.directoryPick,
+      ...(platform === 'win32' ? [DESKTOP_IPC.windowsMenu] : []),
     ].sort())
     const handler = harness.protocolHandle.mock.calls[0]![1]
     expect((await handler(new Request('dsh-app://shell/plugin-manager.html'))).status).toBe(404)
   })
 
-  it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => {
+  it.each(['darwin', 'win32', 'linux'] as const)('uses platform-owned titlebar styling on %s', async (platform) => {
     vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -199,6 +206,10 @@ describe('desktop main startup', () => {
     expect(window.urls).toEqual(['dsh-app://app/'])
     if (platform === 'darwin') {
       expect(window.options).toMatchObject({ titleBarStyle: 'hiddenInset', vibrancy: 'sidebar', backgroundColor: '#00000000' })
+    } else if (platform === 'win32') {
+      expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT } })
+      expect(window.options).not.toHaveProperty('vibrancy')
+      expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null)
     } else {
       expect(window.options).not.toHaveProperty('titleBarStyle')
       expect(window.options).not.toHaveProperty('vibrancy')
@@ -206,7 +217,76 @@ describe('desktop main startup', () => {
     expect(harness.hosts).toHaveLength(0)
   })
 
-  it.each(['darwin', 'win32', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
+  it('follows the Windows primary document language and palette without trusting other frames', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+    listener({ ...event, senderFrame: { url: 'dsh-app://app/' } }, 'zh-CN', '#ffffff', '#000000')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
+    listener(event, 'zh-CN', 'rgb(249, 250, 251)', '#0f1115')
+    expect(window.setTitleBarOverlay).toHaveBeenCalledWith({ color: 'rgb(249, 250, 251)', symbolColor: '#0f1115' })
+    window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
+    expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: '复制', accelerator: '' }])
+    listener(event, 'en', '#1b1b1c', '#f9fafb')
+    window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
+    expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }])
+    window.setTitleBarOverlay.mockClear()
+    listener(event, 'en', 'url(file:///bad)', '#fff')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
+    listener(event, {}, '#fff', '#000')
+    expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' })
+    window.setTitleBarOverlay.mockClear()
+    window.webContents.mainFrame.url = 'dsh-app://shell/plugin-manager.html'
+    listener(event, 'zh-CN', '#fff', '#000')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
+    expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null)
+  })
+
+  it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+    const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
+    appearance(event, 'zh-CN', '#fff', '#000')
+    const handler = harness.handlers.get(DESKTOP_IPC.windowsMenu)!
+    const foreignEvent = { ...event, sender: {} }
+    expect(() => handler(foreignEvent, 'application', 48, 34)).toThrow('rejected sender')
+    expect(() => handler(event, 'arbitrary-command', 48, 34)).toThrow('invalid popup request')
+    expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
+    const application = handler(event, 'application', 48, 34)
+    expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
+      '检查更新…', 'separator', '退出',
+    ])
+    expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
+    expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
+    harness.popup.mock.lastCall![0].callback!()
+    await application
+    const edit = handler(event, 'edit', 104, 34)
+    expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
+      '撤销', '重做', 'separator', '剪切', '复制', '粘贴', '删除', 'separator', '全选',
+    ])
+    const commands = harness.menu.buildFromTemplate.mock.lastCall![0].filter(item => item.type !== 'separator')
+    for (const [index, keyCode] of ['Z', 'Y', 'X', 'C', 'V', 'Delete', 'A'].entries()) {
+      const click = commands[index]!.click as () => void
+      click()
+      const modifiers = keyCode === 'Delete' ? [] : ['control']
+      expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 1, { type: 'keyDown', keyCode, modifiers })
+      expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 2, { type: 'keyUp', keyCode, modifiers })
+    }
+    harness.popup.mock.lastCall![0].callback!()
+    await edit
+    const preventDefault = vi.fn()
+    window.webContents.emit('before-input-event', { preventDefault }, { type: 'keyDown', control: true, key: ',' })
+    expect(preventDefault).not.toHaveBeenCalled()
+    expect(harness.windows).toHaveLength(1)
+  })
+
+  it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
     vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -283,7 +363,8 @@ describe('desktop main startup', () => {
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('offers native editing actions on right-click and only copy for selected read-only text', async () => {
+  it('retains macOS native editing actions on right-click and only copy for selected read-only text', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
     await import('../src/main.ts')
     await harness.preparing.promise
     const window = harness.windows[0]!

+ 10 - 0
apps/desktop/tests/preload-app.spec.ts

@@ -1,4 +1,5 @@
 import { afterEach, expect, it, vi } from 'vitest'
+import { syncWindowsAppearance } from '../src/preload-windows.ts'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 const electron = vi.hoisted(() => ({
@@ -8,6 +9,7 @@ const electron = vi.hoisted(() => ({
 vi.mock('electron', () => electron)
 vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
 vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 
@@ -47,3 +49,11 @@ it('exposes a directory picker only to the local application document', async ()
     expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false)
   }
 })
+
+it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])(
+  'installs Windows appearance only for the application document (%s)', async (url) => {
+    vi.stubGlobal('location', new URL(url))
+    await import('../src/preload-app.ts')
+    expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0)
+  },
+)

+ 143 - 0
apps/desktop/tests/preload-menu.client.spec.ts

@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { installWindowsMenu } from '../src/preload-menu.ts'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+
+const invoke = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>())
+vi.mock('electron', () => ({ ipcRenderer: { invoke } }))
+let menu: ReturnType<typeof installWindowsMenu> | undefined
+
+beforeEach(() => {
+  const appSeat = document.createElement('div')
+  appSeat.dataset.shellOverlay = ''
+  document.body.append(appSeat)
+  document.documentElement.lang = 'en'
+  invoke.mockResolvedValue(undefined)
+})
+
+it('keeps caption menus absent until the application frame replaces loading', async () => {
+  document.body.replaceChildren()
+  menu = installWindowsMenu()
+  const loading = document.createElement('div')
+  loading.dataset.dshBoot = ''
+  document.body.append(loading)
+  await new Promise<void>((resolve) => { queueMicrotask(resolve) })
+  expect(document.querySelector('[data-windows-menu]')).toBeNull()
+  const appSeat = document.createElement('div')
+  appSeat.dataset.shellOverlay = ''
+  loading.replaceWith(appSeat)
+  await vi.waitFor(() => { expect(document.querySelector('[data-windows-menu]')).not.toBeNull() })
+})
+
+it('does not mount menus after a loading document is disposed', async () => {
+  document.body.replaceChildren()
+  menu = installWindowsMenu()
+  menu.dispose()
+  const appSeat = document.createElement('div')
+  appSeat.dataset.shellOverlay = ''
+  document.body.append(appSeat)
+  await new Promise<void>((resolve) => { queueMicrotask(resolve) })
+  expect(document.querySelector('[data-windows-menu]')).toBeNull()
+})
+afterEach(() => {
+  menu?.dispose()
+  menu = undefined
+  document.body.replaceChildren()
+  document.documentElement.lang = ''
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  vi.clearAllMocks()
+})
+
+it('localizes caption entries and removes the menu on disposal', () => {
+  menu = installWindowsMenu()
+  const host = document.querySelector('[data-windows-menu]')!
+  const bar = host.shadowRoot!.querySelector('[role=menubar]')!
+  expect(bar.outerHTML).toMatchSnapshot('english')
+  document.documentElement.lang = 'zh-CN'
+  menu.update()
+  expect(bar.outerHTML).toMatchSnapshot('chinese')
+  menu.dispose()
+  menu = undefined
+  expect(document.querySelector('[data-windows-menu]')).toBeNull()
+})
+
+it('opens native menus without stealing pointer focus and resets popup state when closed', async () => {
+  let close!: () => void
+  invoke.mockImplementation(() => new Promise<void>((resolve) => { close = resolve }))
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  vi.spyOn(button, 'getBoundingClientRect').mockReturnValue(new DOMRect(48, 6, 90, 28))
+  const pointer = new MouseEvent('pointerdown', { cancelable: true })
+  button.dispatchEvent(pointer)
+  expect(pointer.defaultPrevented).toBe(true)
+  const mouse = new MouseEvent('mousedown', { cancelable: true })
+  button.dispatchEvent(mouse)
+  expect(mouse.defaultPrevented).toBe(true)
+  button.click()
+  expect(invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowsMenu, 'application', 48, 34)
+  expect(button.getAttribute('aria-expanded')).toBe('true')
+  close()
+  await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
+})
+
+it('moves between menu entries with arrow keys and opens the focused entry with ArrowDown', () => {
+  menu = installWindowsMenu()
+  const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button')
+  buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true }))
+  expect(buttons[0]!.tabIndex).toBe(-1)
+  expect(buttons[1]!.tabIndex).toBe(0)
+  buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(invoke).toHaveBeenCalledWith(DESKTOP_IPC.windowsMenu, 'edit', 0, 0)
+})
+
+it('restores a text input and its selection before opening a keyboard menu', async () => {
+  const input = document.createElement('input')
+  input.value = 'editor text'
+  document.body.append(input)
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  input.focus()
+  input.setSelectionRange(2, 6, 'backward')
+  button.focus()
+  input.setSelectionRange(0, 0)
+  button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(document.activeElement).toBe(input)
+  expect([input.selectionStart, input.selectionEnd, input.selectionDirection]).toEqual([2, 6, 'backward'])
+  await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
+})
+
+it('reports a failed popup request and clears the active menu', async () => {
+  const error = new Error('popup rejected')
+  const log = vi.spyOn(console, 'error').mockImplementation(() => {})
+  invoke.mockRejectedValue(error)
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  button.click()
+  await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('Desktop caption menu failed', error) })
+  expect(button.getAttribute('aria-expanded')).toBe('false')
+})
+
+it('restores a contenteditable selection before opening Edit with the keyboard', async () => {
+  const editor = document.createElement('div')
+  editor.contentEditable = 'true'
+  editor.setAttribute('contenteditable', 'true')
+  editor.tabIndex = 0
+  editor.textContent = 'editable text'
+  document.body.append(editor)
+  menu = installWindowsMenu()
+  const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button')
+  editor.focus()
+  const range = document.createRange()
+  range.setStart(editor.firstChild!, 2)
+  range.setEnd(editor.firstChild!, 8)
+  document.getSelection()!.removeAllRanges()
+  document.getSelection()!.addRange(range)
+  buttons[0]!.focus()
+  document.getSelection()!.removeAllRanges()
+  buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true }))
+  buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(document.activeElement).toBe(editor)
+  expect(document.getSelection()!.toString()).toBe('itable')
+  await vi.waitFor(() => { expect(buttons[1]!.getAttribute('aria-expanded')).toBe('false') })
+})

+ 55 - 0
apps/desktop/tests/preload-windows.client.spec.ts

@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+import { syncWindowsAppearance } from '../src/preload-windows.ts'
+
+const send = vi.hoisted(() => vi.fn())
+vi.mock('electron', () => ({ ipcRenderer: { send } }))
+vi.mock('../src/preload-menu.ts', () => ({ installWindowsMenu: () => ({ update: vi.fn(), dispose: vi.fn() }) }))
+
+afterEach(() => {
+  window.dispatchEvent(new Event('pagehide'))
+  document.documentElement.removeAttribute('data-windows-titlebar')
+  document.documentElement.style.removeProperty('--dsh-windows-titlebar-height')
+  document.documentElement.lang = 'en'
+  document.body.removeAttribute('data-ds-dark-theme')
+  vi.restoreAllMocks()
+  send.mockClear()
+})
+
+it.each(['darwin', 'linux'] as const)('does not install Windows controls on %s', (platform) => {
+  vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+  syncWindowsAppearance()
+  expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(false)
+  expect(send).not.toHaveBeenCalled()
+})
+
+it('synchronizes live language and palette changes and stops observing a closed document', async () => {
+  vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+  vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
+  vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => ({
+    backgroundColor: document.body.hasAttribute('data-ds-dark-theme') ? 'oklch(0.2 0 0)' : 'hsl(0 0% 100%)',
+    color: 'black',
+  }) as CSSStyleDeclaration)
+  const context = {
+    fillStyle: '', clearRect: vi.fn(), fillRect: vi.fn(),
+    getImageData: () => ({ data: new Uint8ClampedArray(context.fillStyle === 'black'
+      ? [0, 0, 0, 255] : context.fillStyle.startsWith('oklch') ? [27, 27, 28, 255] : [255, 255, 255, 255]) }),
+  }
+  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as unknown as CanvasRenderingContext2D)
+  document.documentElement.lang = 'en'
+  syncWindowsAppearance()
+  expect(send).not.toHaveBeenCalled()
+  expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(true)
+  window.dispatchEvent(new Event('DOMContentLoaded'))
+  expect(document.documentElement.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe('40px')
+  expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'en', 'rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 1)')
+  document.documentElement.lang = 'zh-CN'
+  document.body.setAttribute('data-ds-dark-theme', '')
+  await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'zh-CN', 'rgba(27, 27, 28, 1)', 'rgba(0, 0, 0, 1)') })
+  window.dispatchEvent(new Event('pagehide'))
+  send.mockClear()
+  document.documentElement.lang = 'en'
+  await new Promise<void>((resolve) => { queueMicrotask(resolve) })
+  expect(send).not.toHaveBeenCalled()
+})

+ 86 - 0
apps/web/tests/context-meter.e2e.ts

@@ -0,0 +1,86 @@
+/** Context details stay inside the viewport with and without Chat's statistics contribution. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { describe, expect, it } from 'vitest'
+import { fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
+const NO_CHAT = fileURLToPath(new URL('./fixtures/context-meter-no-chat.patch.yml', import.meta.url))
+
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placement', () => {
+  it.each([true, false])('keeps details visible across viewport changes (Chat statistics: %s)', async (withStats) => {
+    const fixture = await selectedSessionFixture(FIXTURE, false)
+    const scaffold = await launchWebScaffold({
+      replayFixture: fixture,
+      compareReplaySession: false,
+      paceMs: 5,
+      ...withStats ? {} : { extraOverlayPath: NO_CHAT },
+    })
+    try {
+      const browser = await chromium.launch()
+      try {
+        const page = await newEnglishPage(browser)
+        const tripwire = watchConsole(page)
+        await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+        await connectFreshWorkspace(page, scaffold.workspaceCwd)
+        const card = page.locator('[data-composer-card]')
+        expect(await card.evaluate(element =>
+          element.parentElement!.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom,
+        )).toBe(0)
+        const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8'))
+        expect(prompts).toHaveLength(1)
+        const settled = scaffold.whenTurnSettled()
+        const input = page.locator('[data-composer-input]').first()
+        await input.fill(prompts[0]!)
+        await input.press('Enter')
+        await settled
+        const trigger = page.getByRole('button', { name: /% of context used$/ })
+        await trigger.waitFor()
+        expect(await trigger.textContent()).toMatch(/^\d+%$/)
+        expect(await page.getByRole('button', { name: /tok · Cache hit/ }).count()).toBe(withStats ? 1 : 0)
+        expect(await card.evaluate((element) => {
+          const dock = element.nextElementSibling!
+          return {
+            above: getComputedStyle(dock).paddingTop,
+            below: getComputedStyle(element.parentElement!).paddingBottom,
+          }
+        })).toEqual({ above: '4px', below: '4px' })
+        await trigger.click()
+        const panel = page.getByRole('dialog', { name: 'of context used', exact: true })
+        await panel.waitFor()
+        for (const width of [390, 800, 1280]) {
+          await page.setViewportSize({ width, height: 900 })
+          await page.locator('[data-sidebar-collapsed="true"]').waitFor({
+            state: width <= 800 ? 'attached' : 'detached',
+          })
+          await page.evaluate(async () => {
+            await Promise.all(document.getAnimations()
+              .filter(animation => animation.effect?.getComputedTiming().iterations !== Infinity)
+              .map(animation => animation.finished.catch(() => undefined)))
+          })
+          await expect.poll(async () => {
+            const rect = await panel.boundingBox()
+            return rect !== null && rect.x >= 12 && rect.x + rect.width <= width - 12
+              && rect.y >= 12 && rect.y + rect.height <= 888
+          }).toBe(true)
+        }
+        await panel.getByText('System prompt', { exact: true }).click()
+        expect(await panel.isVisible()).toBe(true)
+        await page.keyboard.press('Escape')
+        await panel.waitFor({ state: 'hidden' })
+        await trigger.click()
+        await panel.waitFor()
+        await page.mouse.click(1260, 400)
+        await panel.waitFor({ state: 'hidden' })
+        expect(tripwire.pageErrors).toEqual([])
+        expect(tripwire.warnings).toEqual([])
+      } finally {
+        await browser.close()
+      }
+    } finally {
+      await scaffold.close()
+    }
+  })
+})

+ 1 - 1
apps/web/tests/expected/cordis-history/ui.expected.md

@@ -122,7 +122,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "0% of context used"
 - button "Send message" [disabled]
 - button "3 turns 7 steps":
   - img
@@ -130,3 +129,4 @@
 - button "66.8K tok · Cache hit 77%":
   - img
   - text: 66.8K tokCache hit 77%
+- button "0% of context used": 0%

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

@@ -23,7 +23,7 @@
     - text: Agent 如何派发工具调用。
   - listitem:
     - button "查看 Subagent": Subagent
-    - text: 控制 Agent 为 Subagent 选择模型的权限。
+    - text: 设置 Subagent 的递归层级、数量和模型。
   - listitem:
     - button "查看 网页搜索": 网页搜索
     - text: DeepSeek 搜索提供方。

+ 29 - 0
apps/web/tests/expected/plugin-config/subagent.expected.md

@@ -0,0 +1,29 @@
+- button "返回插件列表":
+  - img
+  - text: 插件列表
+- heading "Subagent" [level=3]
+- paragraph: 设置 Subagent 的递归层级、数量和模型。
+- 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]

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

@@ -23,7 +23,7 @@
     - text: Agent 如何派发工具调用。
   - listitem:
     - button "查看 Subagent": Subagent
-    - text: 控制 Agent 为 Subagent 选择模型的权限。
+    - text: 设置 Subagent 的递归层级、数量和模型。
   - listitem:
     - button "查看 网页搜索": 网页搜索
     - text: DeepSeek 搜索提供方。

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

@@ -23,7 +23,7 @@
     - text: Agent 如何派发工具调用。
   - listitem:
     - button "查看 Subagent": Subagent
-    - text: 控制 Agent 为 Subagent 选择模型的权限。
+    - text: 设置 Subagent 的递归层级、数量和模型。
   - listitem:
     - button "查看 网页搜索": 网页搜索
     - text: DeepSeek 搜索提供方。

+ 1 - 1
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -52,7 +52,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "0% of context used"
 - button "Send message" [disabled]
 - button "1 turns 1 steps · {{throughput}} tok/s":
   - img
@@ -60,3 +59,4 @@
 - button "272 tok · Cache hit 0%":
   - img
   - text: 272 tokCache hit 0%
+- button "0% of context used": 0%

+ 1 - 1
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -44,7 +44,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "0% of context used"
 - button "Send message" [disabled]
 - button "1 turns 1 steps · {{throughput}} tok/s":
   - img
@@ -52,3 +51,4 @@
 - button "272 tok · Cache hit 0%":
   - img
   - text: 272 tokCache hit 0%
+- button "0% of context used": 0%

+ 1 - 1
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -58,7 +58,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "0% of context used"
 - button "Send message" [disabled]
 - button "1 turns 2 steps · {{throughput}} tok/s":
   - img
@@ -66,3 +65,4 @@
 - button "40 tok · Cache hit 0%":
   - img
   - text: 40 tokCache hit 0%
+- button "0% of context used": 0%

+ 1 - 1
apps/web/tests/expected/steer-all/settled.expected.md

@@ -46,7 +46,6 @@
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
-- button "0% of context used"
 - button "Send message" [disabled]
 - button "1 turns 2 steps · {{throughput}} tok/s":
   - img
@@ -54,3 +53,4 @@
 - button "40 tok · Cache hit 0%":
   - img
   - text: 40 tokCache hit 0%
+- button "0% of context used": 0%

+ 2 - 0
apps/web/tests/fixtures/context-meter-no-chat.patch.yml

@@ -0,0 +1,2 @@
+- id: ui-chat
+  disabled: true

+ 63 - 2
apps/web/tests/plugin-config.e2e.ts

@@ -103,12 +103,72 @@ describe('web e2e: plugin configuration pages', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('persists selected adapter routes as the subagent model allowlist', async () => {
+  it('saves subagent limits and resets them to the deployment defaults', async () => {
+    const panel = await openPlugins()
+    await openPage(panel, 'Subagent')
+    const depth = panel.getByLabel('最大递归深度', { exact: true })
+    const capacity = panel.getByLabel('Subagent 并行数量上限', { exact: true })
+    expect(await depth.inputValue()).toBe('1')
+    expect(await capacity.inputValue()).toBe('8')
+    await depth.fill('2')
+    await capacity.fill('12')
+    await panel.getByRole('button', { name: '保存', exact: true }).click()
+    await expect.poll(() => panel.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
+    await expect.poll(settingsDocument).toContain('maxActiveSubagents: 12')
+    await expect.poll(settingsDocument).toContain('maxDepth: 2')
+    await openPlugins()
+    await openPage(panel, 'Subagent')
+    const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
+    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 panel.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
+    await depth.fill('2')
+    await panel.getByRole('button', { name: '恢复默认', exact: true }).first().click()
+    await panel.getByRole('button', { name: '恢复默认', exact: true }).first().click()
+    await panel.getByRole('button', { name: '保存', exact: true }).click()
+    await expect.poll(() => panel.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
+    await openPlugins()
+    await openPage(panel, 'Subagent')
+    expect(await depth.inputValue()).toBe('1')
+    expect(await capacity.inputValue()).toBe('8')
+    await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
+  })
+
+  it('opens field explanations with the keyboard and retains unsaved edits', async () => {
+    const panel = await openPlugins()
+    await openPage(panel, 'Subagent')
+    const depth = panel.getByLabel('最大递归深度', { exact: true })
+    await depth.fill('2')
+    const depthHelp = panel.getByRole('button', { name: '最大递归深度说明', exact: true })
+    expect(await panel.getByRole('region', { name: '最大递归深度说明', exact: true }).count()).toBe(0)
+    await depthHelp.press('Enter')
+    const depthRules = panel.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 panel.getByRole('button', { name: 'Subagent 并行数量上限说明', exact: true }).click()
+    const capacityRules = panel.getByRole('region', { name: 'Subagent 并行数量上限说明', exact: true })
+    expect(await capacityRules.getByText('同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', { exact: true }).count()).toBe(1)
+    await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
+    await openPage(panel, 'Subagent')
+    expect(await depth.inputValue()).toBe('1')
+  })
+
+  it('saves limits and the model allowlist together from the shared card', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
     const panel = await openPlugins()
     await openPage(panel, 'Subagent')
     const toggle = panel.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
 
+    await panel.getByLabel('最大递归深度', { exact: true }).fill('2')
     await toggle.click()
     const models = panel.getByRole('group', { name: 'Agent 可选择的模型' })
     await models.waitFor({ timeout: 10_000 })
@@ -119,6 +179,7 @@ describe('web e2e: plugin configuration pages', () => {
 
     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:')
@@ -255,6 +316,6 @@ describe('web e2e: plugin configuration pages', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md', 'subagent.expected.md'])
   })
 })

+ 15 - 6
apps/web/tests/reference-composer.e2e.ts

@@ -47,6 +47,15 @@ async function settledSourceOption(menu: Locator): Promise<Locator> {
   return source
 }
 
+// Clear retained suggestions and insert one complete query so an intermediate
+// prefix cannot satisfy the caller's wait for a ready result row.
+async function replaceReferenceQuery(page: Page, input: Locator, text: string): Promise<void> {
+  await writeComposerDraft(page, input, '')
+  await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
+  await input.click()
+  await page.keyboard.insertText(text)
+}
+
 /** Build one closed source session with a stable title for reference discovery. */
 function sourceSessionFixture(): string {
   const session = Session.create(SessionId(SOURCE_SESSION_ID))
@@ -293,7 +302,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // Settle: Enter on the highlighted folder row resolves the folder itself
     // as an atomic chip — folder glyph, no trigger character, one unit.
-    await writeComposerDraft(page, input, '@folderx')
+    await replaceReferenceQuery(page, input, '@folderx')
     // First folder query on this page: allow the Host index a cold start.
     await menu.getByRole('option', { name: /^folderx\// }).waitFor({ timeout: 60_000 })
     await page.keyboard.press('Enter')
@@ -304,7 +313,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // Tab drills: the literal descent text stays editable and the open menu
     // lists the folder's children.
-    await writeComposerDraft(page, input, '@folderx')
+    await replaceReferenceQuery(page, input, '@folderx')
     await menu.getByRole('option', { name: /^folderx\// }).waitFor()
     await page.keyboard.press('Tab')
     await expect.poll(() => input.textContent()).toBe('@folderx/')
@@ -312,7 +321,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // The row chevron drills the same way by pointer, header included: a
     // pointer descent reaches the same listing a Tab descent does.
-    await writeComposerDraft(page, input, '@folderx')
+    await replaceReferenceQuery(page, input, '@folderx')
     const row = menu.getByRole('option', { name: /^folderx\// })
     await row.waitFor()
     await row.getByRole('button', { name: 'Browse folder' }).click()
@@ -337,12 +346,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     const crumbs = page.getByRole('navigation', { name: 'Folder navigation' })
 
     // A path the user typed carries its own context: no header.
-    await writeComposerDraft(page, input, '@folderx/')
+    await replaceReferenceQuery(page, input, '@folderx/')
     await menu.getByRole('option', { name: /child\.txt/ }).waitFor({ timeout: 60_000 })
     await expect.poll(() => crumbs.count()).toBe(0)
 
     // The same listing reached by drilling owes the user the way back.
-    await writeComposerDraft(page, input, '@folderx')
+    await replaceReferenceQuery(page, input, '@folderx')
     await menu.getByRole('option', { name: /^folderx\// }).waitFor()
     await page.keyboard.press('Tab')
     await menu.getByRole('option', { name: /child\.txt/ }).waitFor()
@@ -357,7 +366,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // A crumb above the current step re-lists that directory and keeps the
     // header, which now names the step it returned to.
-    await writeComposerDraft(page, input, '@folderx/nested')
+    await replaceReferenceQuery(page, input, '@folderx/nested')
     await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).count()).toBe(0)
     const nested = menu.getByRole('option', { name: /^nested\// })
     await nested.waitFor()

+ 1 - 0
apps/web/tsconfig.json

@@ -80,6 +80,7 @@
     "tests/present-svg.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/composer-placeholder.e2e.ts",
+    "tests/context-meter.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
     "tests/file-upload-round.e2e.ts",

+ 2 - 4
packages/client/ui-chat/src/client/chat/StatsPills.module.css

@@ -6,11 +6,9 @@
   display: flex;
   justify-content: center;
   gap: 12px;
-  max-width: var(--dsh-chat-content-width);
-  width: 100%;
-  margin: 0 auto;
+  min-width: 0;
+  max-width: 100%;
   box-sizing: border-box;
-  padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
 }

+ 1 - 3
packages/client/ui-chat/src/client/chat/StatsPills.tsx

@@ -330,10 +330,8 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
   const hasTokens = usage !== undefined
     && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
   if (stats.steps === 0 && !hasTokens) return null
-  // data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
-  // tightens the composer's bottom clearance only while this row renders.
   return (
-    <div className={css.root} data-composer-stats>
+    <div className={css.root}>
       {stats.steps > 0 && (
         <TimePill
           stats={stats}

+ 0 - 4
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -161,9 +161,6 @@ describe('StatsPills', () => {
   it('renders the counts reading and usage pill and hides a brand-new empty session', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsPills {...props(source)} />)
-    // InputBar's `.root:has([data-composer-stats])` bottom-clearance rule keys
-    // off this attribute: present exactly while the row renders.
-    expect(view.container.querySelector('[data-composer-stats]')).toBeTruthy()
     // No timing on the fixture: the speed segment drops out and the dialog
     // would have no rows, so the counts reading stays a static pill (no button).
     expect(view.getByText('1 turns 1 steps').closest('button')).toBeNull()
@@ -179,7 +176,6 @@ describe('StatsPills', () => {
       contextPressure: {},
     })} />)
     expect(emptyView.container.textContent).toBe('')
-    expect(emptyView.container.querySelector('[data-composer-stats]')).toBeNull()
   })
 
   it.each([

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: 3853c0bd1c7a69563cf25383449f1949c5d3e66a
-README.zh.md: 362a4aff3371e9690f070edd545825eb6ccb61bd
+README.md: 23690a0c71f9a3b81639fa763c6a095e1bf93011
+README.zh.md: 251fa9c6513773cfca3e092742a045b0ac57030c

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
+The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
+
 The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
 
 `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
+上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
+
 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
 
 `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。

+ 19 - 16
packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css

@@ -1,28 +1,33 @@
-/* Context-occupancy ring beside the send button plus its click-open breakdown
+/* Context-occupancy pill below the composer plus its click-open breakdown
    panel (menu surface: r12, elevation-prominent). */
 
 .root {
-  position: relative;
   display: inline-flex;
+  flex: none;
 }
 
-/* Same 28px circular hit target family as the composer's attach button. */
 .trigger {
-  display: grid;
-  place-items: center;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
   flex: none;
-  width: 28px;
-  height: 28px;
+  padding: 1px 8px;
   border: none;
-  border-radius: 999px;
-  corner-shape: round;
+  border-radius: 24px;
   background: transparent;
-  color: var(--dsw-alias-label-secondary);
+  color: var(--dsw-alias-label-tertiary);
+  font-family: inherit;
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-variant-numeric: tabular-nums;
+  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
+  white-space: nowrap;
   cursor: pointer;
 }
 
-.trigger:hover {
+.trigger:hover,
+.trigger[aria-expanded='true'] {
   background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
 }
 
 .track {
@@ -39,12 +44,10 @@
 }
 
 .panel {
-  position: absolute;
-  bottom: calc(100% + 8px);
-  right: 0;
-  z-index: 100;
+  position: fixed;
+  z-index: 1100;
   box-sizing: border-box;
-  width: 264px;
+  width: min(264px, calc(100vw - 24px));
   padding: 12px;
   border: 0;
   border-radius: 12px;

+ 25 - 15
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -1,14 +1,15 @@
-/** Composer context-occupancy meter: a ring beside the send button fed by the
+/** Composer context-occupancy meter: a ring and percentage below the card fed by the
  * `contextPressure` projection, with a click-open panel of the heuristic
  * `contextBreakdown` composition (system prompt, tools, conversation).
  * Renders nothing until a provider reports both pressure and a route
  * capacity. */
 
 import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
 import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
 // Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
 import type {} from '@deepseek-ai/dsh-token-meter/client'
-import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { contextOccupancy } from '../context-occupancy.ts'
 import css from './ContextMeter.module.css'
@@ -57,8 +58,18 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
   const breakdown = useProjection('contextBreakdown')
   const [open, setOpen] = useState(false)
   const rootRef = useRef<HTMLSpanElement | null>(null)
+  const panelRef = useRef<HTMLDivElement | null>(null)
   const context = contextOccupancy(pressure)
   const available = context !== null
+  const position = useAnchoredPosition({
+    open: open && available,
+    anchorRef: rootRef,
+    panelRef,
+    side: 'top',
+    gap: 8,
+    margin: 12,
+  })
+  useDismissOnOutsidePointer(rootRef, open && available, setOpen, panelRef)
 
   // A model switch can temporarily remove capacity while this component stays
   // mounted. Close the now-unavailable panel instead of preserving stale UI.
@@ -66,22 +77,13 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
     if (!available && open) setOpen(false)
   }, [available, open])
 
-  // Outside click / Escape close, one document listener while open (Menu's pattern).
   useEffect(() => {
     if (!open || !available) return
-    const onPointerDown = (e: PointerEvent): void => {
-      if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
-      setOpen(false)
-    }
     const onKeyDown = (e: KeyboardEvent): void => {
       if (e.key === 'Escape') setOpen(false)
     }
-    document.addEventListener('pointerdown', onPointerDown)
     document.addEventListener('keydown', onKeyDown)
-    return () => {
-      document.removeEventListener('pointerdown', onPointerDown)
-      document.removeEventListener('keydown', onKeyDown)
-    }
+    return () => { document.removeEventListener('keydown', onKeyDown) }
   }, [available, open])
 
   if (context === null) return null
@@ -125,10 +127,17 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
               transform="rotate(-90 7 7)"
             />
           </svg>
+          <span>{reading}</span>
         </button>
       </Tooltip>
-      {open && (
-        <div className={css.panel} role="dialog" aria-label={t('context.used')}>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={css.panel}
+          style={position ?? { visibility: 'hidden', left: 0, top: 0 }}
+          role="dialog"
+          aria-label={t('context.used')}
+        >
           <div className={css.header}>
             {/* Empty sides collapse through `.headline:empty` so the locale that
                 needs no leading (or trailing) text spends no header gap. */}
@@ -163,7 +172,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
               ))}
             </dl>
           )}
-        </div>
+        </div>,
+        document.body,
       )}
     </span>
   )

+ 12 - 6
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -7,14 +7,20 @@
      the sides narrow with the shared width axis); the bottom gradient mask
      is owned by the chat scroller. No top pad: the composer stack's gap owns
      the space above; the status strip still carries its own margin. */
-  padding: 0 var(--dsh-composer-side-clearance) 8px;
+  padding: 0 var(--dsh-composer-side-clearance) 4px;
 }
 
-/* A mounted stats row in the composer dock (data-composer-stats, ui-chat
-   StatsPills) brings its own 4px top pad, so the root's bottom clearance
-   tightens to keep the drawn B8 rhythm. */
-.root:has([data-composer-stats]) {
-  padding-bottom: 4px;
+.dock {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  max-width: 100%;
+  padding-top: 4px;
+}
+
+.hero .dock:empty {
+  display: none;
 }
 
 .hero {

+ 6 - 4
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -442,7 +442,6 @@ export const InputBar = memo(function InputBar({
               ? null
               : renderSlot('conversation.input.right', {})}
             {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
-            <ContextMeter useProjection={useProjection} t={t} />
             {interruptible && (
               <Tooltip label={t('input.stop')} side="top" delayMs={500} disabled={stop === undefined}>
                 <button
@@ -482,9 +481,12 @@ export const InputBar = memo(function InputBar({
           </div>
         </div>
       </div>
-      {variant === 'composer' && input !== undefined && sessionId !== undefined
-        ? renderSlot('conversation.composer.dock', {})
-        : null}
+      <div className={css.dock}>
+        {variant === 'composer' && input !== undefined && sessionId !== undefined
+          ? renderSlot('conversation.composer.dock', {})
+          : null}
+        <ContextMeter useProjection={useProjection} t={t} />
+      </div>
     </div>
   )
 })

+ 14 - 14
packages/client/ui-conversation/tests/context-meter.client.spec.tsx

@@ -51,9 +51,9 @@ describe('ContextMeter', () => {
       contextBreakdown: BREAKDOWN,
     })
     const trigger = view.getByRole('button', { name: '上下文已用 25%' })
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
     fireEvent.click(trigger)
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).toContain('25%')
     expect(panel.textContent).toContain('上下文已用')
@@ -64,7 +64,7 @@ describe('ContextMeter', () => {
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
     // Clicking the trigger again toggles the panel shut.
     fireEvent.click(trigger)
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 
   it('lets each locale own the headline word order around the reading', () => {
@@ -76,11 +76,11 @@ describe('ContextMeter', () => {
     fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
     // The reading follows the label in Chinese and leads it in English; both
     // headers read as one sentence rather than a concatenated fragment.
-    expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
+    expect(zhView.queryByRole('dialog')!.textContent)
       .toMatch(/^上下文已用25%/)
     const enView = meter(values, tEn)
     fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
-    expect(enView.container.querySelector('[role="dialog"]')!.textContent)
+    expect(enView.queryByRole('dialog', { name: 'of context used' })!.textContent)
       .toMatch(/^25%of context used/)
   })
 
@@ -90,7 +90,7 @@ describe('ContextMeter', () => {
       contextBreakdown: BREAKDOWN,
     })
     fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     // `.segment` carries a min-width, so a zero-width part would still paint a
     // filled sliver over an empty context.
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
@@ -106,13 +106,13 @@ describe('ContextMeter', () => {
     })
     const trigger = view.getByRole('button', { name: '上下文已用 2%' })
     fireEvent.click(trigger)
-    expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
+    expect(view.queryByRole('dialog')!.textContent).toContain('~3K / 128K')
   })
 
   it('omits the composition rows while the contextBreakdown projection is absent', () => {
     const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
     fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).not.toContain('系统提示词')
     expect(panel.textContent).not.toContain('对话消息')
@@ -127,7 +127,7 @@ describe('ContextMeter', () => {
     }
     const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
     fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
-    expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
+    expect(view.queryByRole('dialog')).not.toBeNull()
 
     values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
     view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
@@ -139,7 +139,7 @@ describe('ContextMeter', () => {
     }
     view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
     expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 
   it('closes on outside pointerdown and Escape — but not inside clicks', () => {
@@ -150,17 +150,17 @@ describe('ContextMeter', () => {
     const trigger = view.getByRole('button', { name: '上下文已用 25%' })
     const openPanel = () => {
       fireEvent.click(trigger)
-      return view.container.querySelector('[role="dialog"]')!
+      return view.queryByRole('dialog')!
     }
     // A pointerdown inside the panel keeps it open; outside closes it.
     const again = openPanel()
     fireEvent.pointerDown(again)
-    expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
+    expect(view.queryByRole('dialog')).not.toBeNull()
     fireEvent.pointerDown(document.body)
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
     // Escape.
     openPanel()
     fireEvent.keyDown(document, { key: 'Escape' })
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 })

+ 1 - 0
packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx

@@ -22,6 +22,7 @@ vi.mock(desktopIpc.electron, () => ({
 }))
 vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
 vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
 
 afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 

+ 2 - 2
packages/client/ui-layout/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-layout/README.md
-README.md: f150416b7fa2712bd3aae0096c9e6c823a7ddce4
-README.zh.md: 05e49b554770f6927ce92e9b42f26769facaa1ba
+README.md: 582c5fbea271caf0f45f9ee22c10d21f7412bf38
+README.zh.md: 14e3f429284c5c3a519fa47e1156a510b7271f91

+ 2 - 0
packages/client/ui-layout/README.md

@@ -29,6 +29,8 @@ The root slot composes the sidebar, main content, and right column. The sidebar
 
 Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
 
+Windows Electron's `data-windows-titlebar` marker reserves the caption height above all columns and removes the collapsed sidebar rail. Only the content area's top-left corner has a 16px radius; the other corners and the internal divider remain square. The frame publishes `--dsh-windows-content-radius` and `--dsh-windows-sidebar-width` for ui-sidebar-right's fullscreen corner and sidebar clearance. Ordinary Web documents do not receive the marker; macOS retains its separate layout.
+
 ### Theme presentation
 
 The presenter consumes resolved theme snapshots and projects them onto the document: `html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens and `--dsh-content-font-size` as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background. Disposing the presenter removes its metadata node with its other global writes.

+ 2 - 0
packages/client/ui-layout/README.zh.md

@@ -29,6 +29,8 @@ kind: "package-reference"
 
 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
 
+Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。框架发布 `--dsh-windows-content-radius` 和 `--dsh-windows-sidebar-width`,供 ui-sidebar-right 的全屏圆角及侧栏避让使用。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
+
 ### 主题呈现
 
 呈现器消费解析后的主题快照,并投影到 document:`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,把主题的别名 token 与 `--dsh-content-font-size` 设为 body 上的内联变量,并持有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新。对呈现器执行 dispose(资源释放)时,它会连同其他全局写入一起移除自己的元数据节点。

+ 32 - 0
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -16,6 +16,38 @@
   transition: none;
 }
 
+/* The Windows caption row remains outside every conversation and file panel. */
+:global([data-windows-titlebar]) .frame {
+  --dsh-windows-content-radius: 16px;
+  box-sizing: border-box;
+  padding-top: var(--dsh-windows-titlebar-height);
+  grid-template-rows: minmax(0, 1fr);
+  background: var(--dsw-specific-sidebar-fill);
+}
+
+:global([data-windows-titlebar]) .centerCol {
+  background: var(--dsw-alias-bg-base);
+  border-radius: var(--dsh-windows-content-radius) 0 0 0;
+  corner-shape: round;
+}
+
+:global([data-windows-titlebar]) .frame::before {
+  content: '';
+  position: absolute;
+  inset: 0 0 auto;
+  height: var(--dsh-windows-titlebar-height);
+  background: var(--dsw-specific-sidebar-fill);
+  -webkit-app-region: drag;
+}
+
+:global([data-windows-titlebar]) .sidebarCol {
+  border-right: none;
+}
+
+:global([data-windows-titlebar]) .handle {
+  top: var(--dsh-windows-titlebar-height);
+}
+
 @media (prefers-reduced-motion: reduce) {
   .frame {
     transition: none;

+ 5 - 4
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -163,10 +163,9 @@ export function AppFrame({
     ? 0
     : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar
   const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
-  // macOS desktop (data-platform from the Electron preload) hides the closed
-  // sidebar entirely: its reopen controls live in the session header instead
-  // of an icon rail.
-  const collapsedWidth = document.documentElement.dataset.platform === 'darwin' ? 0 : SIDEBAR_COLLAPSED
+  // Desktop reopen controls occupy the macOS session header or Windows caption row.
+  const collapsedWidth = document.documentElement.dataset.platform === 'darwin'
+    || document.documentElement.hasAttribute('data-windows-titlebar') ? 0 : SIDEBAR_COLLAPSED
   // Opening on a narrow frame collapses the left sidebar. Eligibility must
   // include that space before the occupant's first shown report arrives.
   const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth)
@@ -208,6 +207,8 @@ export function AppFrame({
       ref={frameRef}
       className={css.frame}
       style={{
+        ...(document.documentElement.hasAttribute('data-windows-titlebar')
+          ? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
         gridTemplateColumns:
           `${cols.sidebar}px minmax(0, 1fr) ${cols.rightbar}px`,
       }}

+ 13 - 0
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -220,6 +220,19 @@ describe('AppFrame', () => {
     expect(getByTestId('rightbar-content')).toBeTruthy()
   })
 
+  it('keeps Windows caption controls mounted with a zero-width collapsed column', () => {
+    document.documentElement.setAttribute('data-windows-titlebar', '')
+    try {
+      const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
+      act(() => { instance.actions.toggleSidebar() })
+      expect(tracks(frame)[0]).toBe(0)
+      expect(sidebarOwner()).toMatchObject({ collapsed: true, width: 0 })
+      expect(getByTestId('sidebar-content')).toBeTruthy()
+    } finally {
+      document.documentElement.removeAttribute('data-windows-titlebar')
+    }
+  })
+
   it('keeps the closed sidebar mounted at its 56px rail without a handle', () => {
     const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
     act(() => { instance.actions.toggleSidebar() })

+ 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: b4f6e40a5bc91322786a9dfcb8d903dd6947c724
-README.zh.md: f4a7a8fa7fffcdfd599c8afcdd95e723253aa236
+README.md: 7d09cfb4f7d77cb8a121e043d94b62cd4ccd823f
+README.zh.md: 649c5c51219856e8d8544db0d4d291343db55c64

+ 6 - 2
packages/client/ui-settings-plugins/README.md

@@ -25,7 +25,7 @@ Use the **Built-in plugins** settings section to inspect the plugins this deploy
 <a id="use-this-package"></a>
 ## Use this package
 
-Open **Built-in plugins** in Settings for the read-only inventory; [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes it as the section's one tab, shown as the page itself. To configure a host-plane plugin, select **Plugins** in the sidebar: the Official group lists one card per plugin this deployment composes, in this order — the shell executor (`shell`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`) — and a card opens the plugin's page with its form.
+Open **Built-in plugins** in Settings for the read-only inventory; [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes it as the section's one tab, shown as the page itself. To configure a host-plane plugin, select **Plugins** in the sidebar: the Official group lists one card per plugin this deployment composes, in this order — the shell executor (`shell`), the agent loop's tool-call parallelism (`agent-loop`), Subagent delegation limits and model selection (`subagent` and `subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`) — and a card opens the plugin's page with its form.
 
 ### What appears here
 
@@ -35,7 +35,11 @@ Each page registers into the Plugins page's `plugins.item` slot while the Host s
 
 A page stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store. Leaving the page drops the drafts; there is no discard control. A failed save keeps the page as it is, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted.
 
-The Subagent page stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the page refreshes them after adapter changes, settings commits, and reconnects.
+The **Subagent** card groups delegation limits and model selection on one page with one save button. **Maximum recursion depth** and **Subagent parallelism 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 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 page appears when either namespace is served and shows only the available sections.
 
 ### Secret-role fields
 

+ 6 - 2
packages/client/ui-settings-plugins/README.zh.md

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

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

@@ -0,0 +1,12 @@
+.section {
+  min-width: 0;
+  padding: 16px 0;
+}
+
+.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 '@deepseek-ai/dsh-client-ui-plugin-manager/client'
+import { PluginConfigForm } from './PluginConfigForm.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<'plugins.item'>
+  & PropsLocale<'settings.plugins'> & InjectFace<SubagentCardFace>
+
+/**
+ * Render the available Subagent settings with one configuration page and save footer.
+ * @param props - Locale, both form snapshots, and their shared actions.
+ * @returns The summary or the available settings form.
+ */
+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.view === 'summary') return t('subagentDescription')
+  const state = subagentCardShell(limits, models)
+  return (
+    <PluginConfigForm t={t}
+      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}
+    </PluginConfigForm>
+  )
+}

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

+ 15 - 24
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx → packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx

@@ -1,30 +1,26 @@
-/** User control for model-selectable subagent delegation in new sessions, as its configuration page. */
+/** User control for model-selectable subagent delegation in new sessions. */
 
-import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
 import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
   SubagentModelCandidate,
   SubagentModelSelectionCardFace,
+  SubagentModelSelectionCardState,
 } from './subagent-model-selection-card-controller.ts'
-import { PluginConfigForm } from './PluginConfigForm.tsx'
-import css from './SubagentModelSelectionCard.module.css'
+import css from './SubagentModelSelectionFields.module.css'
 
-/** Props the renderer binds for the subagent model-selection page. */
-export type SubagentModelSelectionCardProps =
-  PropsRuntime<'plugins.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 preference's one-liner, or the default-off preference and its exact adapter-route choices, as the Plugins page asks.
- * @param props - the view asked for, locale copy, the form snapshot, and its toggle action.
- * @returns the one-liner, or the preference form.
+ * Render the default-off preference and its exact adapter-route choices.
+ * @param props - locale copy, the card snapshot, and its toggle action.
+ * @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)
-  if (props.view === 'summary') return t('subagentModelSelectionDescription')
+export function SubagentModelSelectionFields(props: SubagentModelSelectionFieldsProps) {
+  const { t, state } = props
   const availableGroups = new Map<string, {
     providerName: string
     candidates: SubagentModelCandidate[]
@@ -63,12 +59,7 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
     </label>
   )
   return (
-    <PluginConfigForm
-      t={t}
-      state={state}
-      onSave={props.save}
-      onDiscard={props.discard}
-    >
+    <>
       <div className={css.permission}>
         <div className={css.toggleRow}>
           <span className={css.toggleLabel}>{t('subagentModelSelectionToggle')}</span>
@@ -132,6 +123,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
       {state.conflicted
         ? <p className={css.conflict} role="status">{t('subagentModelSelectionConflict')}</p>
         : null}
-    </PluginConfigForm>
+    </>
   )
 }

+ 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-tertiary);
+  cursor: pointer;
+}
+
+.helpButton:hover,
+.helpButton[aria-expanded='true'] {
+  background: var(--dsw-alias-bg-layer-4);
+  color: var(--dsw-alias-label-secondary);
+}
+
+.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 size={12} />
+              </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>
   )
 }

+ 24 - 18
packages/client/ui-settings-plugins/src/client/index.ts

@@ -3,8 +3,8 @@
  * pages, browser half. The Settings section is the shell around the
  * feature-owned tabs registered into `settings.plugins.tab` (the read-only
  * inventory ships one); the configuration pages this package ships register
- * into the Plugins page's `plugins.item` slot, one per host-plane namespace
- * the deployment exposes, and appear in the page's Official group. Each page
+ * into the Plugins page's `plugins.item` slot, for the host-plane namespaces
+ * the deployment exposes, and appear in the page's Official group. Each form
  * binds its namespace through the client settings scope, which keeps the
  * pages unaware of one another and of the section.
  */
@@ -26,7 +26,9 @@ import { AgentLoopCard } from './AgentLoopCard.tsx'
 import { BashCard } from './BashCard.tsx'
 import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
 import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
-import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
+import { SubagentCard } from './SubagentCard.tsx'
+import { subagentCardFace } from './subagent-card-controller.ts'
+import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts'
 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'
@@ -66,10 +68,13 @@ export function apply(ctx: ClientContext): void {
   const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
   const webSearch = new WebSearchCardController(
     ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx)
+  const subagentLimits = new SubagentLimitsCardController(ctx.settingsScope.bind({ namespace: 'subagent' }))
   const subagentModelSelection = new SubagentModelSelectionCardController(
     ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
     ctx,
   )
+  const subagentLimitsFace = subagentLimits.inject()
+  const subagentModelsFace = subagentModelSelection.inject()
 
   // The credential a page reports is not part of any settings section, so its
   // scope publishes nothing when one is written. This is the only signal that
@@ -92,27 +97,26 @@ export function apply(ctx: ClientContext): void {
   )
   ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
 
-  // One configuration page per host-plane namespace, registered while the Host
-  // serves that namespace: a deployment that does not compose the owning plugin
-  // shows no trace of it. Card registration order is the page order, not the
+  // Configuration pages register while the Host serves their namespaces.
+  // A deployment without those plugins shows no trace of them. Card registration order is the page order, not the
   // Host's description order, which follows plugin activation and can change
   // between boots.
-  const pages: ReadonlyArray<readonly [namespace: string, register: () => () => void]> = [
-    [SHELL_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
+  const pages: ReadonlyArray<readonly [namespaces: readonly [string, ...string[]], register: () => () => void]> = [
+    [[SHELL_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
       name: 'plugins.item', id: 'bash', order: 10, label: () => t('bashTitle'), locale: NS, inject: () => bash.inject(),
     }, BashCard))],
-    [AGENT_LOOP_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
+    [[AGENT_LOOP_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
       name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('agentLoopTitle'), locale: NS, inject: () => agentLoop.inject(),
     }, AgentLoopCard))],
-    [SUBAGENT_MODEL_SELECTION_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
+    [['subagent', SUBAGENT_MODEL_SELECTION_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
       name: 'plugins.item',
-      id: 'subagent-model-selection',
+      id: 'subagent',
       order: 30,
-      label: () => t('subagentModelSelectionTitle'),
+      label: () => t('subagentTitle'),
       locale: NS,
-      inject: () => subagentModelSelection.inject(),
-    }, SubagentModelSelectionCard))],
-    [WEB_SEARCH_NS, () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
+      inject: () => subagentCardFace(subagentLimitsFace, subagentModelsFace),
+    }, SubagentCard))],
+    [[WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
       name: 'plugins.item', id: 'web-search', order: 40, label: () => t('webSearchTitle'), locale: NS, inject: () => webSearch.inject(),
     }, WebSearchCard))],
   ]
@@ -122,10 +126,12 @@ export function apply(ctx: ClientContext): void {
     const registered = new Map<string, () => void>()
     const sync = (): void => {
       const served = new Set(describeFace.getSnapshot().view?.namespaces.map(view => view.ns) ?? [])
-      for (const [namespace, register] of pages) {
+      for (const [namespaces, register] of pages) {
+        const namespace = namespaces[0]
+        const available = namespaces.some(namespace => served.has(namespace))
         const off = registered.get(namespace)
-        if (served.has(namespace) && off === undefined) registered.set(namespace, register())
-        else if (!served.has(namespace) && off !== undefined) {
+        if (available && off === undefined) registered.set(namespace, register())
+        else if (!available && off !== undefined) {
           off()
           registered.delete(namespace)
         }

+ 41 - 7
packages/client/ui-settings-plugins/src/client/locales.ts

@@ -11,7 +11,15 @@ export type PluginsSettingsLocaleKey =
   | 'webSearchTitle' | 'webSearchDescription'
   | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
   | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
-  | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
+  | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle'
+  | 'subagentMaxDepth'
+  | 'subagentDepthHelpLabel' | 'subagentDepthHelp'
+  | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride'
+  | 'subagentMaxActive'
+  | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp'
+  | 'subagentDepthInvalid'
+  | 'subagentCapacityInvalid'
+  | 'subagentModelSelectionTitle'
   | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed'
   | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry'
   | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
@@ -53,10 +61,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.',
-  subagentModelSelectionTitle: 'Subagent',
-  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.',
+  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 parallelism limit',
+  subagentCapacityHelpLabel: 'About the Subagent parallelism limit',
+  subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. The main Agent is excluded. New start requests are rejected when the limit is reached.',
+  subagentDepthInvalid: 'Enter a whole number of 0 or more.',
+  subagentCapacityInvalid: 'Enter a whole number of 1 or more.',
+  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.',
@@ -105,8 +126,21 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   webSearchBaseUrlHint: '留空则使用提供方默认地址。',
   webSearchMaxUses: '单次请求最多搜索次数',
   webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
-  subagentModelSelectionTitle: 'Subagent',
-  subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。',
+  subagentTitle: 'Subagent',
+  subagentDescription: '设置 Subagent 的递归层级、数量和模型。',
+  subagentLimitsTitle: '运行限制',
+  subagentMaxDepth: '最大递归深度',
+  subagentDepthHelpLabel: '最大递归深度说明',
+  subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。',
+  subagentDepthZero: '禁用 Subagent',
+  subagentDepthOne: '仅允许主 Agent 创建 Subagent',
+  subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。',
+  subagentMaxActive: 'Subagent 并行数量上限',
+  subagentCapacityHelpLabel: 'Subagent 并行数量上限说明',
+  subagentCapacityHelp: '同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。',
+  subagentDepthInvalid: '请输入不小于 0 的整数。',
+  subagentCapacityInvalid: '请输入不小于 1 的整数。',
+  subagentModelSelectionTitle: '模型选择',
   subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型',
   subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。',
   subagentModelSelectionAllowed: 'Agent 可选择的模型',

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

@@ -0,0 +1,79 @@
+/** 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']
+  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.
+ * @returns Framework-bound sources and shared save/discard actions.
+ */
+export function subagentCardFace(
+  limits: SubagentLimitsCardFace,
+  models: SubagentModelSelectionCardFace,
+): SubagentCardFace {
+  return {
+    hooks: { ...limits.hooks, ...models.hooks },
+    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()
+    },
+  }
+}

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

@@ -0,0 +1,61 @@
+/** Staged delegation limits backed by the Host's subagent settings section. */
+
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
+import { CardForm, numberField, type CardActions, type CardFieldSpec, type CardFieldState, type CardShell } from './card-form.ts'
+
+/** Host-owned delegation defaults and live capacity. */
+export interface SubagentLimitsSettings {
+  maxDepth: number
+  maxActiveSubagents: number
+}
+
+/** Effective values and drafts presented by the limits card. */
+export interface SubagentLimitsCardState extends CardShell {
+  maxDepth: CardFieldState
+  maxActiveSubagents: CardFieldState
+}
+
+/** Actions and observable state bound by the slot renderer. */
+export interface SubagentLimitsCardFace extends CardActions {
+  hooks: {
+    subagentLimitsCard: SnapshotStore<SubagentLimitsCardState>
+  }
+}
+
+function limitField(field: keyof SubagentLimitsSettings, minimum: number): CardFieldSpec {
+  const numeric = numberField(field)
+  return {
+    ...numeric,
+    parse: (text) => {
+      const write = numeric.parse(text)
+      if (write?.kind !== 'set') return write
+      const value = write.value as number
+      return Number.isSafeInteger(value) && value >= minimum && !Object.is(value, -0) ? write : undefined
+    },
+  }
+}
+
+/** Bind two independently resettable limits to one staged settings form. */
+export class SubagentLimitsCardController {
+  private readonly form: CardForm<SubagentLimitsSettings>
+  private readonly store: SnapshotStore<SubagentLimitsCardState>
+
+  /** @param scope - The Host's `subagent` settings section. */
+  constructor(scope: SettingsScope<SubagentLimitsSettings>) {
+    this.form = new CardForm(scope, [limitField('maxDepth', 0), limitField('maxActiveSubagents', 1)])
+    this.store = this.form.bind(() => ({
+      ...this.form.shell(),
+      maxDepth: this.form.field('maxDepth'),
+      maxActiveSubagents: this.form.field('maxActiveSubagents'),
+    }))
+  }
+
+  /**
+   * Bind the limits editor to the slot renderer.
+   * @returns The limits snapshot and staged write actions.
+   */
+  inject(): SubagentLimitsCardFace {
+    return { hooks: { subagentLimitsCard: this.store }, ...this.form.actions() }
+  }
+}

+ 33 - 5
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -1,7 +1,7 @@
 /** What the browser half registers, and that it all leaves with the fiber. */
 
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
@@ -92,7 +92,7 @@ describe('ui-settings-plugins apply', () => {
   })
 
   it('injects a live tab projection and one business face per configuration page', async () => {
-    const { ctx, slots } = await bench(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek'])
+    const { ctx, slots } = await bench(['shell', 'agent-loop', 'subagent', 'subagent-model-selection', 'web-search-deepseek'])
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
 
@@ -116,8 +116,7 @@ describe('ui-settings-plugins apply', () => {
     await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) })
     for (const entry of slots.entries('plugins.item')) {
       const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
-      // Each page injects exactly one snapshot store plus its own actions.
-      expect(Object.keys(face.hooks)).toHaveLength(1)
+      expect(Object.keys(face.hooks)).toHaveLength(entry.options.id === 'subagent' ? 2 : 1)
     }
   })
 
@@ -129,11 +128,40 @@ describe('ui-settings-plugins apply', () => {
 
     await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) })
     const entries = slots.entries('plugins.item')
-    expect(entries.map(entry => entry.options.id)).toEqual(['bash', 'agent-loop', 'subagent-model-selection', 'web-search'])
+    expect(entries.map(entry => entry.options.id)).toEqual(['bash', 'agent-loop', 'subagent', 'web-search'])
     expect(entries.map(entry => resolveSlotLabel(entry.options.label))).toEqual(['终端', 'Agent 循环', 'Subagent', '网页搜索'])
     expect(entries.every(entry => entry.locale === 'settings.plugins')).toBe(true)
   })
 
+  it.each([
+    ['subagent'],
+    ['subagent-model-selection'],
+    ['subagent', 'subagent-model-selection'],
+  ])('keeps one Subagent page while either namespace is served: %j', async (...served) => {
+    const { ctx, slots, describeSettings, remote } = await bench(served)
+    onTestFinished(() => ctx.fiber.dispose())
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    await vi.waitFor(() => {
+      expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['subagent'])
+    })
+    const entry = slots.entries('plugins.item')[0]
+    for (const namespaces of [['subagent-model-selection'], ['subagent'], []]) {
+      describeSettings.mockResolvedValue({
+        ok: true,
+        value: {
+          writable: true, hasDocument: true,
+          namespaces: namespaces.map(ns => ({ ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 })),
+        },
+      })
+      remote.emit('settings/document-updated', ['subagent', 1])
+      await vi.waitFor(() => {
+        expect(ctx.settingsScope.describe().getSnapshot().view?.namespaces.map(view => view.ns)).toEqual(namespaces)
+        expect(slots.entries('plugins.item')).toEqual(namespaces.length > 0 ? [entry] : [])
+      })
+    }
+  })
+
   it('registers the pages of the served namespaces only, and withdraws one the Host stops serving', async () => {
     // ui-theme is served but belongs to another surface, and a deployment
     // composing no PowerShell/POSIX executor serves no `bash` at all.

+ 143 - 13
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -1,17 +1,17 @@
 // @vitest-environment jsdom
 
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+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 { 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'
 import { BashCard } from '../src/client/BashCard.tsx'
 import type { BashCardProps } from '../src/client/BashCard.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'
@@ -73,17 +73,29 @@ function renderBash(state: Partial<BashCardState> = {}) {
   return renderBashCard(state).actions
 }
 
-function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}, view: ConfigView = 'page') {
-  const store = createSnapshotStore<SubagentModelSelectionCardState>({
+function renderSubagent(
+  limitState: Partial<SubagentLimitsCardState> = {},
+  modelState: Partial<SubagentModelSelectionCardState> = {},
+  view: ConfigView = 'page',
+) {
+  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(),
@@ -94,10 +106,15 @@ function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardS
     ...actions,
     view,
     t,
-    useSubagentModelSelectionCard: bindSnapshotSelector(store),
-  } as unknown as SubagentModelSelectionCardProps
-  render(<SubagentModelSelectionCard {...props} />)
-  return actions
+    useSubagentLimitsCard: bindSnapshotSelector(limits),
+    useSubagentModelSelectionCard: bindSnapshotSelector(models),
+  } as unknown as SubagentCardProps
+  render(<SubagentCard {...props} />)
+  return { actions, limits, models }
+}
+
+function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}, view: ConfigView = 'page') {
+  return renderSubagent({ available: false }, state, view).actions
 }
 
 describe('PluginsSettingsSection', () => {
@@ -275,7 +292,7 @@ describe('BashCard', () => {
   })
 })
 
-describe('SubagentModelSelectionCard', () => {
+describe('Subagent model selection fields', () => {
   it('renders the default-off preference in its staged plugin card', () => {
     const actions = renderSubagentModelSelection()
 
@@ -363,12 +380,11 @@ describe('SubagentModelSelectionCard', () => {
 
   it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => {
     renderSubagentModelSelection({}, 'summary')
-    expect(document.body.textContent).toBe(en.subagentModelSelectionDescription)
+    expect(document.body.textContent).toBe(en.subagentDescription)
 
     cleanup()
     renderSubagentModelSelection({ available: false })
     expect(screen.getByRole('status').textContent).toBe(en.unavailable)
-    expect(screen.queryByRole('switch')).toBeNull()
 
     cleanup()
     const actions = renderSubagentModelSelection({ writable: false })
@@ -506,3 +522,117 @@ describe('WebSearchCard', () => {
     expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
   })
 })
+
+
+describe('SubagentCard', () => {
+  it('discards both drafts when leaving the page', () => {
+    const { actions } = renderSubagent({ dirty: true }, { dirty: true })
+    cleanup()
+    expect(actions.discard).toHaveBeenCalledOnce()
+  })
+
+  it('renders limits without model selection when only limits are served', () => {
+    renderSubagent({}, { available: false })
+    expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy()
+    expect(screen.queryByRole('switch')).toBeNull()
+  })
+
+  it('shows both sections on one page with one save footer', () => {
+    renderSubagent({ dirty: true })
+
+    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)
+  })
+
+  it('reveals field rules on demand without changing staged values', () => {
+    renderSubagent({ dirty: true, maxDepth: field('2') })
+    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 }) })
+    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.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } })
+    fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '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.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 })
+    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 stays open after both settle', () => {
+    const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
+    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.saving })).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.getByRole('switch')).toHaveProperty('disabled', false)
+    expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
+  })
+
+  it('keeps a rejected section open after the other section saves', () => {
+    const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
+    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 model-only deployments without limit controls', () => {
+    renderSubagent({ available: false })
+    expect(screen.getByRole('switch')).toBeTruthy()
+    expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull()
+  })
+})

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

@@ -7,6 +7,8 @@ import { describe, expect, it, vi } from 'vitest'
 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 {
@@ -1014,3 +1016,135 @@ describe('WebSearchCardController', () => {
     expect(credentials.set).not.toHaveBeenCalled()
   })
 })
+
+describe('SubagentLimitsCardController', () => {
+  it('validates staged limits, saves them, and restores composed defaults', async () => {
+    const host = stubSettingsScope<SubagentLimitsSettings>()
+    const face = new SubagentLimitsCardController(host.scope).inject()
+    const state = () => face.hooks.subagentLimitsCard.getSnapshot()
+    host.publish({ status: 'ready', writable: true, value: { maxDepth: 3, maxActiveSubagents: 8 }, base: { maxDepth: 3, maxActiveSubagents: 8 }, user: {} })
+    acceptWrites(host)
+    expect(state().maxActiveSubagents.text).toBe('8')
+    for (const draft of ['-1', '1.5', '9007199254740992', 'wat', '-0']) {
+      face.edit('maxDepth', draft)
+      expect(state().invalid).toBe(true)
+    }
+    face.edit('maxDepth', '0')
+    face.edit('maxActiveSubagents', '0')
+    expect(state().invalid).toBe(true)
+    face.edit('maxActiveSubagents', '12')
+    expect(host.set).not.toHaveBeenCalled()
+    face.save()
+    await vi.waitFor(() => { expect(state().saving).toBe(false) })
+    expect(host.scope.getSnapshot().value).toEqual({ maxDepth: 0, maxActiveSubagents: 12 })
+    face.resetField('maxDepth')
+    face.edit('maxActiveSubagents', '')
+    expect(state().invalid).toBe(false)
+    face.save()
+    await vi.waitFor(() => { expect(state().saving).toBe(false) })
+    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>()
+    const set = vi.spyOn(limits.scope, '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(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/client/ui-sidebar-right/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-sidebar-right/README.md
-README.md: a2ad8e17e58e04f10efb97236951cd172c654f95
-README.zh.md: 5ce03a0f712aa4ca8ef29404ff1a75467d2ec317
+README.md: e838f952530d71510c14cbb948d64c8fac319787
+README.zh.md: a529c31c8abb4c4927683e128cc0938a155e51c0

+ 2 - 0
packages/client/ui-sidebar-right/README.md

@@ -43,6 +43,8 @@ Normal and fullscreen presentations share the same content tree, so switching do
 | `push` (default) | Panel width: the conversation makes room | In the track; its left edge and the conversation's right edge travel together, on the frame's own curve |
 | `fullscreen` | Retains the wide-screen normal track; automatic narrow-screen fullscreen takes no track | Covers the entire viewport |
 
+In Windows Electron, `html[data-windows-titlebar]` keeps fullscreen panels below the caption and to the right of the expanded sidebar. The frame's `--dsh-windows-sidebar-width` supplies that inset and maximum width; `--dsh-windows-content-radius` supplies the top-left radius. Only fullscreen panels clip overflow for that corner.
+
 The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available.
 
 The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tab capsules with close controls where allowed, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip.

+ 2 - 0
packages/client/ui-sidebar-right/README.zh.md

@@ -43,6 +43,8 @@ kind: "package-reference"
 | `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 |
 | `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 |
 
+在 Windows Electron 中,`html[data-windows-titlebar]` 让全屏面板保持在顶栏下方及展开侧栏的右侧。框架的 `--dsh-windows-sidebar-width` 提供该缩进及最大宽度;`--dsh-windows-content-radius` 提供左上圆角。仅全屏面板为该圆角裁剪溢出内容。
+
 席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。
 
 面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:在允许时带关闭按钮的 tab 胶囊,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。

+ 10 - 0
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css

@@ -59,6 +59,16 @@
   -webkit-app-region: no-drag;
 }
 
+/* Windows keeps caption controls above fullscreen file and terminal panels. */
+:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] {
+  overflow: hidden;
+  left: var(--dsh-windows-sidebar-width);
+  max-width: calc(100% - var(--dsh-windows-sidebar-width));
+  top: var(--dsh-windows-titlebar-height);
+  border-radius: var(--dsh-windows-content-radius) 0 0 0;
+  corner-shape: round;
+}
+
 /* macOS fullscreen: the panel reaches the window's top-left corner, where the
    hiddenInset traffic lights (x 16..~68) float over the first pane's tab
    strip. Widen that strip's start inset (dockkit publishes the variable);

+ 2 - 2
packages/client/ui-sidebar/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-sidebar/README.md
-README.md: 783ef070442922ec4c0b56143b1131a4f381aa11
-README.zh.md: 4bd5e822b41a1b9a305a4605cfa6e0529962d232
+README.md: 14ef1e69eabc4089f5d4d8279a3acb6de5fcabd7
+README.zh.md: 20b48eb01653560e0245bb84f68c842b609ee265

+ 2 - 0
packages/client/ui-sidebar/README.md

@@ -39,6 +39,8 @@ Plugins add an icon component to the root-scoped `sidebar.panellist` list with a
 
 During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation.
 
+On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region.
+
 ### macOS desktop
 
 Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the conversation header's `conversation.session.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).

+ 2 - 0
packages/client/ui-sidebar/README.zh.md

@@ -39,6 +39,8 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 
 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。
 
+在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
+
 ### macOS 桌面
 
 在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向会话头部的 `conversation.session.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。

+ 83 - 0
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -35,6 +35,89 @@
   padding: 18px 10px 6px;
 }
 
+/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
+:global([data-windows-titlebar]) .root .logoRow {
+  height: 40px;
+  margin: 0;
+  padding: 0;
+}
+
+:global([data-windows-titlebar]) .toggle {
+  position: fixed;
+  top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+  left: 12px;
+  z-index: 30;
+  -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .root.collapsed .logoRow {
+  height: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
+  padding-left: 4px;
+}
+
+:global([data-windows-titlebar]) .brandIdentity {
+  transform: translateY(1px);
+}
+
+:global([data-windows-titlebar]) .brandMark {
+  transform: translateX(1px);
+}
+
+:global([data-windows-titlebar]) .brandName {
+  font-weight: 400;
+}
+
+:global([data-windows-titlebar]) .root.collapsed {
+  padding: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
+  margin-top: 8px;
+  margin-left: 0;
+}
+
+:global([data-windows-titlebar]) .collapsed .panelList,
+:global([data-windows-titlebar]) .collapsed .regionArea,
+:global([data-windows-titlebar]) .collapsed .footArea {
+  display: none;
+}
+
+:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
+  --dsh-windows-menu-start: 84px;
+}
+
+:global([data-windows-titlebar]) .collapsed .newSession {
+  position: fixed;
+  top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+  left: 48px;
+  margin: 0;
+  padding: 0;
+  border: none;
+  z-index: 30;
+  -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .railIn .iconButton,
+:global([data-windows-titlebar]) .railIn .newSession {
+  animation: none;
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle,
+:global([data-windows-titlebar]) .collapsed .newSession {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  corner-shape: round;
+  color: var(--dsw-alias-label-secondary);
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
+  display: inline;
+}
+
 /* Scrollbars in the column are a pointer affordance: the shell adds this
    class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
    and rebinding ui-theme's indirection pair to `transparent` takes the thumb

+ 5 - 4
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -105,7 +105,8 @@ export function SidebarRoot({
     const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
     return () => { window.clearTimeout(timer) }
   }, [collapsed])
-  const wide = !collapsed || !settled
+  const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar')
+  const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
 
   // Freeze the content at its expanded width while it fades out (collapsed
   // && wide): the sliding column then clips it instead of reflowing it. The
@@ -175,13 +176,13 @@ export function SidebarRoot({
         aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
         onClick={() => { toggleSidebar() }}
       >
-        {!wide && (
+        {!wide && !windowsTitlebar && (
           <span className={css.railMark} aria-hidden="true">
             {renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })}
           </span>
         )}
         {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
-        <IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
+        <IconPanelLeftOutline16 className={css.panelIcon} size={wide || windowsTitlebar ? 16 : 18} />
       </button>
     </Tooltip>
   )
@@ -243,7 +244,7 @@ export function SidebarRoot({
           aria-label={t('session.new.label')}
           onClick={() => { startSession() }}
         >
-          <IconNewChatOutline16 size={wide ? 14 : 18} />
+          <IconNewChatOutline16 size={wide ? 14 : windowsTitlebar ? 16 : 18} />
           {wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
         </button>
       </Tooltip>

+ 204 - 0
packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap

@@ -1,5 +1,209 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`sidebar shell snapshots > renders Windows caption controls in expanded and collapsed states > windows collapsed 1`] = `
+<div
+  data-slot="sidebar"
+  style="display: contents;"
+>
+  <div
+    class="root collapsed railIn quietBars"
+    style=""
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="Open sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="16"
+          viewBox="0 0 16 16"
+          width="16"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="16"
+        viewBox="0 0 16 16"
+        width="16"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+    </button>
+    <div
+      class="regionArea"
+    >
+      <div
+        data-slot="sidebar.workspaces"
+        style="display: contents;"
+      />
+    </div>
+    <div
+      class="footArea"
+    >
+      <div
+        class="footerActions"
+      >
+        <div
+          data-slot="sidebar.footer.action"
+          style="display: contents;"
+        />
+      </div>
+      <div
+        class="settingsArea"
+      >
+        <div
+          data-slot="sidebar.settings"
+          style="display: contents;"
+        />
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`sidebar shell snapshots > renders Windows caption controls in expanded and collapsed states > windows expanded 1`] = `
+<div
+  data-slot="sidebar"
+  style="display: contents;"
+>
+  <div
+    class="root quietBars"
+    style="width: 300px;"
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="New session"
+        class="brand wide"
+        type="button"
+      >
+        <span
+          aria-hidden="true"
+          class="brandIdentity"
+        >
+          <span
+            class="brandMark"
+          >
+            <div
+              data-slot="sidebar.brand.mark"
+              style="display: contents;"
+            >
+              <svg
+                aria-hidden="true"
+                data-content="965fe321"
+                fill="none"
+                height="17.6580310880829"
+                viewBox="0 0 23.16 17.04"
+                width="24"
+              />
+            </div>
+          </span>
+          <span
+            class="brandName"
+          >
+            <div
+              data-slot="sidebar.brand.name"
+              style="display: contents;"
+            >
+              <span
+                class="localBuildBrand"
+              >
+                <span
+                  class="localBuildTitle"
+                >
+                  DSH Local Build
+                </span>
+                <span
+                  class="buildVersion"
+                >
+                  1.2.3-rc.4-abc1234-dirty
+                </span>
+              </span>
+            </div>
+          </span>
+        </span>
+      </button>
+      <button
+        aria-label="Collapse sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="16"
+          viewBox="0 0 16 16"
+          width="16"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="14"
+        viewBox="0 0 16 16"
+        width="14"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+      <span
+        class="newSessionLabel wide"
+      >
+        New Session
+      </span>
+    </button>
+    <div
+      class="regionArea"
+    >
+      <div
+        data-slot="sidebar.workspaces"
+        style="display: contents;"
+      />
+    </div>
+    <div
+      class="footArea"
+    >
+      <div
+        class="footerActions"
+      >
+        <div
+          data-slot="sidebar.footer.action"
+          style="display: contents;"
+        />
+      </div>
+      <div
+        class="settingsArea"
+      >
+        <div
+          data-slot="sidebar.settings"
+          style="display: contents;"
+        />
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
 exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
 <div
   data-slot="sidebar"

+ 15 - 0
packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx

@@ -96,4 +96,19 @@ describe('sidebar shell snapshots', () => {
     expect(slot.view.queryByRole('button', { name: '新建会话' })).toBeNull()
     await runtime.dispose()
   })
+
+  it('renders Windows caption controls in expanded and collapsed states', async () => {
+    document.documentElement.setAttribute('data-windows-titlebar', '')
+    const { runtime } = await bench({ locale: 'en' })
+    try {
+      const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
+      expect(slot.container).toMatchSnapshot('windows expanded')
+      slot.update({ collapsed: true, width: 0 })
+      expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
+      expect(slot.container).toMatchSnapshot('windows collapsed')
+    } finally {
+      await runtime.dispose()
+      document.documentElement.removeAttribute('data-windows-titlebar')
+    }
+  })
 })

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

@@ -1623,7 +1623,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-ui-settings-plugins BashCard id \'bash\'',
       'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'',
-      'client-ui-settings-plugins SubagentModelSelectionCard id \'subagent-model-selection\'',
+      'client-ui-settings-plugins SubagentCard id \'subagent\'',
       'client-ui-settings-plugins WebSearchCard id \'web-search\'',
     ],
     replaceRisk: 'none',

+ 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: 6e5ee7b657bfbc8ca6bb6ef075e669e4fc65d3ce
-README.zh.md: 9e8b9b89d9d6927b8397705d4375b62f68152903
+README.md: 4634f0a33f71709668732b7070a2bccded3db5c0
+README.zh.md: d3d8a597f23153b0c82e663d98142a9966f48c22

+ 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 Host exposes delegation defaults in the `subagent` settings section. User values override this plugin's composition; reset removes the user override. `maxDepth` defaults to `1` 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 on the **Plugins → Subagent** page edits the Host’s `subagent` settings section. User values override this plugin's composition; reset removes the user override. `maxDepth` defaults to `1` 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"
 
 ### 委派设置
 
-Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插件的组合配置;恢复默认会删除用户覆盖。`maxDepth` 默认为 `1`,在委派工具自身未配置深度时提供默认值。工具显式指定的深度(包括 `provider-managed`)优先。深度 `0` 禁止继承此设置的工具委派;深度 `1` 只允许直接子代理。修改在下一次委派时生效。直接调用服务的调用方仍自行提供可选的请求深度。
+**插件 → Subagent** 页面的限制部分编辑 Host 的 `subagent` 设置分节。用户值覆盖本插件的组合配置;恢复默认会删除用户覆盖。`maxDepth` 默认为 `1`,在委派工具自身未配置深度时提供默认值。工具显式指定的深度(包括 `provider-managed`)优先。深度 `0` 禁止继承此设置的工具委派;深度 `1` 只允许直接子代理。修改在下一次委派时生效。直接调用服务的调用方仍自行提供可选的请求深度。
 
 ### 可续接子代理容量
 

+ 1 - 1
snapshots/web/deepseek-messages-chat/ui.expected.md

@@ -47,7 +47,6 @@
 - button "选择模型,当前 DeepSeek-V4-Flash,推理等级 high":
   - text: DeepSeek-V4-Flash high
   - img
-- button "上下文已用 1%"
 - button "发送消息" [disabled]
 - button "1 轮 1 步 · {{throughput}} tok/s":
   - img
@@ -55,3 +54,4 @@
 - button "8.2K tok · 缓存命中 16%":
   - img
   - text: 8.2K tok缓存命中 16%
+- button "上下文已用 1%": 1%

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels