Răsfoiți Sursa

refactor(plugin-inventory): menu-pill preset switcher, collapsible groups, inline preset-provided rows

Review-driven refinements to the scope-grouped plugin list:

- The preset switcher becomes the General-settings selector pill over the
  shared Menu primitive instead of a native select, and the preset group
  is collapsible like the global one (search still forces both open).
- The session-plugins drawer is removed: rows the presets took over sit
  inline in the global list with the preset-provided tag and per-preset
  details, since the preset group above already shows those compositions.
- The status dot renders only for a live root fiber, so file-state rows
  of an unmounted preset carry their enablement tag alone instead of a
  column of grey dots.
- PresetTree reclaims the owning entry's subtree slot: EntryTree's
  constructor filed the standing mount under the roster's own Loader row,
  so after the first session composed a preset the whole composition
  leaked into root loader.entries() as host rows (each preset overwriting
  the last). A regression test holds the root entry list identical across
  a mount.
Yichen Jiang 2 săptămâni în urmă
părinte
comite
5eb7195f9d

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md
-2026-08-29-plugin-inventory-agent-preset-scopes.md: ec738ee0ff325e14677407da0ce4af8ffe3fe718
-2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: 7804203350b4173a6dfd058447aee95e918cda74
+2026-08-29-plugin-inventory-agent-preset-scopes.md: 8615e057bec7b269fe58c18678d8cc96479d451f
+2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: d71ddf2dc082193c5c7530ba34680102ce8c3d2e

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md

@@ -12,15 +12,15 @@ English | [中文](2026-08-29-plugin-inventory-agent-preset-scopes.zh.md)
 
 **The inventory speaks for both planes.** `pluginInventory/list` gains an optional `agentPresets` block — one group per roster preset with id, display name, default marking, health, and flattened composition rows — supplied by the new `AgentPresets.compositionInventory()`: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file. `dsh-host-plugin-inventory` resolves the roster as an optional peer through `ctx.get('agentPresets')` (the `plugin-package-inventory-deepseek` pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent.
 
-**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read.
+**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. Building this surface also exposed the reverse leak: `EntryTree`'s constructor files every new tree under the nearest owning Loader entry's `subtree` slot, so the first standing mount hung the whole preset composition off the roster's own row and root `loader.entries()` walked it as host entries. `PresetTree` now reclaims the slot, restoring the standing mount's documented absence from the Loader, and a regression test holds the root entry list identical across a mount.
 
-**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first behind a display-only switcher that opens on the default preset and writes no settings inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled folds into a "session plugins" drawer that names its providers — a third state instead of the generic "disabled" that started this. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces the disclosures open, and points at matches sitting in unselected presets.
+**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first, collapsible and open by default, behind a display-only switcher — the General-settings selector pill over a menu — that opens on the default preset and writes no settings, because inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled is marked preset-provided in place, its details naming the enabling presets — a third state instead of the generic "disabled" that started this, and deliberately not a sub-group: the preset group above already shows those plugins as compositions, so a second cluster restating them earned its removal. The status dot appears only for a live root fiber — a file-state row carries its enablement tag alone, so an unmounted preset does not read as a column of grey mystery dots. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces them open, and points at matches sitting in unselected presets.
 
 **The General row is deleted, not relocated.** The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so `ui-agent-preset` drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads.
 
 ## Alternatives considered
 
-**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the drawer's provider list and the search pointers preserve the cross-scope answer the all-at-once layout was buying.
+**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the per-row provider details and the search pointers preserve the cross-scope answer the all-at-once layout was buying.
 
 **Keep file-state gates unevaluated (`conditional` until first mount).** Honest but it re-created the misleading reading this change removes: on a cold host the default preset's `tool-bash` read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md

@@ -12,15 +12,15 @@
 
 **清单同时陈述两个平面。**`pluginInventory/list` 增加可选的 `agentPresets` 块——每个名单预设一组,含 id、显示名、默认标记、健康状态与压平的组合行——由新增的 `AgentPresets.compositionInventory()` 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答。`dsh-host-plugin-inventory` 经 `ctx.get('agentPresets')` 把名单当作可选伙伴解析(即 `plugin-package-inventory-deepseek` 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。
 
-**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。
+**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。搭这个表面还暴露了反向泄漏:`EntryTree` 的构造器把每棵新树挂到最近拥有者 Loader 条目的 `subtree` 槽上,于是第一个 standing mount 把整棵预设组合挂在了 roster 自己的行下,根 `loader.entries()` 把它当宿主条目走了一遍。`PresetTree` 现在归还该槽位,恢复 standing mount「不在 Loader 里」的书面契约;回归测试钉住挂载前后根条目列表逐项相同。
 
-**列表按作用域分组,误导行获得自己的状态。**预设组在前,其切换器只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,收进"会话插件"抽屉并列出提供它的预设——用第三种状态取代引发这一切的笼统"已停用"。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开折叠,并指出未选中预设里的匹配。
+**列表按作用域分组,误导行获得自己的状态。**预设组在前、可折叠且默认展开,其切换器是通用设置同款的「选择胶囊 + 菜单」控件,只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,就地标记为预设提供并在详情里列出启用它的预设——用第三种状态取代引发这一切的笼统"已停用",并且刻意不做成子分组:上方的预设组已经把这些插件按组合展示,一个复述它们的第二个聚簇理应被移除。状态圆点只为存活的根 fiber 渲染——文件态的行只带启停标签,未挂载的预设不会读作一列灰色的谜之圆点。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开分组,并指出未选中预设里的匹配。
 
 **通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 `ui-agent-preset` 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。
 
 ## 考虑过的替代方案
 
-**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,抽屉的提供者列表与搜索指引保留了全展开布局想买到的跨作用域答案。
+**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,行级的提供者详情与搜索指引保留了全展开布局想买到的跨作用域答案。
 
 **文件态门保持不求值(首次挂载前一律 `conditional`)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 `tool-bash` 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。
 

+ 4 - 5
apps/web/tests/settings-chrome.e2e.ts

@@ -103,19 +103,18 @@ describe('web e2e: settings modal and General preferences', () => {
     await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
     await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
     // The preset group opens first with its display-only switcher; the global
-    // plane starts collapsed and expands on demand, session plugins deeper still.
-    const presetSwitcher = dialog.getByRole('combobox', { name: '选择要查看的 Agent 预设' })
+    // plane starts collapsed and expands on demand.
+    const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
     await presetSwitcher.waitFor({ timeout: 10_000 })
     await dialog.getByRole('button', { name: /^全局/ }).click()
     const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
     await pluginRow.waitFor({ timeout: 10_000 })
-    await dialog.getByRole('button', { name: /^会话插件/ }).click()
     const expectedPluginCount = [...scaffold.ctx.loader.entries()]
       .filter(entry => !entry.options.group)
       .length
     expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1)
-    // Every Loader entry appears exactly once in the global group — the
-    // session-plugin drawer included, preset compositions excluded.
+    // Every Loader entry appears exactly once in the global group — rows the
+    // presets took over included, preset compositions excluded.
     expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
       .toBe(expectedPluginCount)
     expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))

+ 2 - 2
packages/client/ui-settings-plugin-inventory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md
-README.md: bc89b60e89e0fe257c6f422a2749bd4188fe796c
-README.zh.md: 13c09aaeb108120ba22f05d9828da75088a7deb8
+README.md: 718139cc135b8213ea2f3d1aec384f550b57a641
+README.zh.md: 7ebb7d75e6ebd285010a72f175c02ab7c2e802fd

+ 4 - 4
packages/client/ui-settings-plugin-inventory/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two groups. The agent-preset group comes first: a display-only switcher over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate only a mount can decide — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and entries disabled globally but enabled by presets fold into a session-plugins drawer naming their providers instead of reading as plainly disabled. Search filters both groups, forces the collapsed disclosures open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded.
+`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two collapsible groups. The agent-preset group comes first, open by default: a display-only switcher pill over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate the Host could not evaluate — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and an entry disabled globally but enabled by at least one preset is marked as preset-provided in place — its details name the enabling presets — instead of reading as plainly disabled. Search filters both groups, forces the collapsed groups open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded.
 
 ## Table of Contents
 
@@ -29,11 +29,11 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe
 
 ### Reading a card
 
-Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a session-plugins drawer row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id.
+Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id.
 
 ### The preset switcher
 
-The switcher lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen.
+The switcher is the same selector-pill-plus-menu control the General settings rows use. It lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen.
 
 ### Retrying a failed read
 
@@ -55,7 +55,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w
 
 ### Rendering
 
-Row keys are scope-qualified (`global:`, `drawer:`, `preset:<id>:<index>`), so one module appearing in several scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The session-plugins drawer is derived client-side: a global entry joins it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
+Row keys are scope-qualified (`global:`, `preset:<id>:<index>`), so one module appearing in both scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
 
 </details>
 

+ 4 - 4
packages/client/ui-settings-plugin-inventory/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两组渲染。Agent 预设组在前:一个只改显示的切换器覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含只有挂载才能裁决的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被预设启用的条目收进「会话插件」抽屉并列出提供它的预设,而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的折叠,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。
+`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两个可折叠分组渲染。Agent 预设组在前、默认展开:一个只改显示的切换器胶囊覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含宿主无法求值的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被至少一个预设启用的条目就地标记为预设提供——详情列出启用它的预设——而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的分组,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。
 
 ## 目录
 
@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 阅读卡片
 
-每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;「会话插件」抽屉行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。
+每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。
 
 ### 预设切换器
 
-切换器列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。
+切换器与通用设置各行使用同一种「选择胶囊 + 菜单」控件。它列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。
 
 ### 重试失败的读取
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 渲染
 
-行 key 按作用域限定(`global:`、`drawer:`、`preset:<id>:<index>`),因此同一模块出现在个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。「会话插件」抽屉在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才归入抽屉,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
+行 key 按作用域限定(`global:`、`preset:<id>:<index>`),因此同一模块出现在个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
 
 </details>
 

+ 39 - 25
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css

@@ -292,36 +292,59 @@
   line-height: 18px;
 }
 
+/* The preset switcher trigger mirrors the General-settings selector pill. */
 .switcher {
-  max-width: 60%;
-  border: 1px solid var(--dsw-alias-border-l2);
-  border-radius: 8px;
-  padding: 3px 8px;
-  background: var(--dsw-alias-bg-layer-1);
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 10px;
+  height: 30px;
+  white-space: nowrap;
+  border: none;
+  border-radius: 15px;
+  padding: 0 12px;
+  background: var(--dsw-alias-bg-module-platform);
   color: var(--dsw-alias-label-primary);
   font: inherit;
   font-size: 13px;
+  line-height: 20px;
   font-weight: 600;
+  cursor: pointer;
 }
 
-.badge {
+.switcher:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.switcher:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.switcher > .chevron {
   flex: none;
-  border-radius: 999px;
-  padding: 1px 8px;
-  font-size: 11px;
-  line-height: 16px;
 }
 
-.badge[data-kind='default'] {
-  background: var(--dsw-alias-label-primary);
-  color: var(--dsw-alias-bg-layer-3);
+.switcherLabel {
+  max-width: 240px;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
-.badge[data-kind='broken'] {
-  background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
-  color: var(--dsw-alias-state-error-primary);
+.iconToggle {
+  flex: none;
+  width: auto;
+  min-height: 0;
+  padding: 4px;
+}
+
+.groupBody {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
 }
 
+
 .brokenNote {
   margin: 0;
   border-radius: 8px;
@@ -355,15 +378,6 @@
   cursor: pointer;
 }
 
-.drawer {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  margin-top: 10px;
-  border: 1px dashed var(--dsw-alias-border-l1);
-  border-radius: 10px;
-  padding: 8px 10px 10px;
-}
 
 .enabledIn {
   display: flex;

+ 84 - 76
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -3,6 +3,7 @@ import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/clien
 import {
   IconChevronDownOutline14,
   IconSearchOutline16,
+  Menu,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginInventoryLocaleKey } from './locales.ts'
@@ -141,13 +142,13 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
   )
 }
 
-/** Status dot naming the root-fiber phase. */
-function PhaseDot({ phase, t }: { readonly phase: PluginFiberPhase; readonly t: Translate }): ReactNode {
+/** Status dot naming a live root-fiber phase; rows with no live fiber show none. */
+function PhaseDot({ phase, t }: { readonly phase: NonNullable<PluginFiberPhase>; readonly t: Translate }): ReactNode {
   const status = phaseLabel(phase, t)
   return (
     <span
       className={css.statusDot}
-      data-phase={phase ?? 'unobserved'}
+      data-phase={phase}
       role="img"
       aria-label={status}
       title={status}
@@ -167,8 +168,9 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
   const [query, setQuery] = useState('')
   const [expanded, setExpanded] = useState<string | null>(null)
   const [chosenPreset, setChosenPreset] = useState<string | null>(null)
+  const [switcherOpen, setSwitcherOpen] = useState(false)
+  const [presetOpen, setPresetOpen] = useState<boolean | null>(null)
   const [globalOpen, setGlobalOpen] = useState<boolean | null>(null)
-  const [drawerOpen, setDrawerOpen] = useState(false)
   const [state, setState] = useState<ViewState>({ status: 'loading' })
 
   useEffect(() => {
@@ -202,21 +204,17 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
 
   const entries = snapshot?.entries ?? []
   const failedEntries: PluginInventoryEntry[] = []
-  const drawerEntries: { entry: PluginInventoryEntry; providers: readonly [AgentPresetGroup, ...AgentPresetGroup[]] }[] = []
   const regularEntries: PluginInventoryEntry[] = []
   for (const entry of entries) {
-    const providers = enabledIn.get(entry.moduleName)
     if (entry.fiberPhase === 'failed') failedEntries.push(entry)
-    else if (!entry.enabled && providers !== undefined) drawerEntries.push({ entry, providers })
     else regularEntries.push(entry)
   }
 
   const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery)
   const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery)
   const filteredFailed = failedEntries.filter(entryMatch)
-  const filteredDrawer = drawerEntries.filter(drawerRow => entryMatch(drawerRow.entry))
   const filteredRegular = regularEntries.filter(entryMatch)
-  const globalCount = filteredFailed.length + filteredDrawer.length + filteredRegular.length
+  const globalCount = filteredFailed.length + filteredRegular.length
   const selectedRows = selected === undefined ? [] : selected.rows.filter(rowMatch)
   const otherPresetMatches = searching
     ? presets.filter(preset => preset !== selected && preset.rows.some(rowMatch))
@@ -224,8 +222,8 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
   const otherMatchCount = otherPresetMatches
     .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0)
 
+  const presetEffectiveOpen = searching || (presetOpen ?? true)
   const globalEffectiveOpen = searching || (globalOpen ?? presets.length === 0)
-  const drawerEffectiveOpen = searching || drawerOpen
   const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0
     && otherPresetMatches.length === 0
 
@@ -258,7 +256,9 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
         ariaLabel={`${title}, ${stateText}`}
         trailing={(
           <>
-            {row.enabled === true && !failed ? <PhaseDot phase={row.fiberPhase} t={t} /> : null}
+            {row.enabled === true && !failed && row.fiberPhase !== null
+              ? <PhaseDot phase={row.fiberPhase} t={t} />
+              : null}
             <StateTag kind={kind} label={stateText} />
           </>
         )}
@@ -278,12 +278,12 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
     )
   }
 
-  /** One global-plane row; a drawer row carries the presets that enable it. */
+  /** One global-plane row; a preset-provided row carries the presets that enable it. */
   const globalRowCard = (
     entry: PluginInventoryEntry,
     providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]],
   ): ReactNode => {
-    const key = `${providers === undefined ? 'global' : 'drawer'}:${entry.entryId}`
+    const key = `global:${entry.entryId}`
     const title = moduleShortName(entry.moduleName)
     const failed = entry.fiberPhase === 'failed'
     const stateText = failed
@@ -302,7 +302,9 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
         ariaLabel={`${title}, ${stateText}`}
         trailing={(
           <>
-            {entry.enabled && !failed ? <PhaseDot phase={entry.fiberPhase} t={t} /> : null}
+            {entry.enabled && !failed && entry.fiberPhase !== null
+              ? <PhaseDot phase={entry.fiberPhase} t={t} />
+              : null}
             <StateTag kind={kind} label={stateText} />
           </>
         )}
@@ -313,7 +315,7 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
           entryId={entry.entryId}
           facts={providers !== undefined
             ? [
-              [t('configuration'), t('drawerDetail')],
+              [t('configuration'), t('presetProvidedDetail')],
               [t('enabledIn'), (
                 <span className={css.enabledIn}>
                   <span>{providers.map(preset => preset.name ?? preset.id).join(' · ')}</span>
@@ -364,43 +366,71 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
           {selected !== undefined ? (
             <section className={css.group} data-plugin-scope="preset" data-preset-id={selected.id}>
               <div className={css.groupHeader}>
-                <select
-                  className={css.switcher}
-                  aria-label={t('switcherLabel')}
-                  value={selected.id}
-                  onChange={(event) => { setChosenPreset(event.currentTarget.value) }}
+                <button
+                  type="button"
+                  className={`${css.groupToggle} ${css.iconToggle}`}
+                  aria-expanded={presetEffectiveOpen}
+                  aria-controls={`${sectionId}-preset`}
+                  aria-label={t('presetSubtitle')}
+                  onClick={() => { setPresetOpen(!presetEffectiveOpen) }}
                 >
-                  {presets.map(preset => (
-                    <option key={preset.id} value={preset.id}>{presetLabel(preset, t)}</option>
-                  ))}
-                </select>
+                  <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
+                </button>
+                <Menu
+                  open={switcherOpen}
+                  onClose={() => { setSwitcherOpen(false) }}
+                  items={presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t) }))}
+                  selectedId={selected.id}
+                  onSelect={(id) => {
+                    setSwitcherOpen(false)
+                    setChosenPreset(id)
+                  }}
+                  portal
+                  anchor={(
+                    <button
+                      type="button"
+                      className={css.switcher}
+                      aria-haspopup="menu"
+                      aria-expanded={switcherOpen}
+                      aria-label={t('switcherLabel')}
+                      onClick={() => { setSwitcherOpen(value => !value) }}
+                    >
+                      <span className={css.switcherLabel}>{presetLabel(selected, t)}</span>
+                      <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
+                    </button>
+                  )}
+                />
                 <span className={css.groupSubtitle}>{t('presetSubtitle')}</span>
                 <span className={css.groupCount} data-preset-plugin-count={selectedRows.length}>
                   {selectedRows.length}
                 </span>
               </div>
-              {selected.broken !== undefined ? (
-                <p className={css.brokenNote} role="alert">{selected.broken}</p>
-              ) : null}
-              {selectedRows.length > 0 ? (
-                <ul className={css.cards}>
-                  {selectedRows.map((row, index) => presetRowCard(selected, row, index))}
-                </ul>
-              ) : null}
-              {otherMatchCount > 0 ? (
-                <p className={css.hint}>
-                  {t('matchesInOtherPresets', { count: String(otherMatchCount) })}
-                  {otherPresetMatches.map(preset => (
-                    <button
-                      key={preset.id}
-                      type="button"
-                      className={css.jumpLink}
-                      onClick={() => { setChosenPreset(preset.id) }}
-                    >
-                      {preset.name ?? preset.id}
-                    </button>
-                  ))}
-                </p>
+              {presetEffectiveOpen ? (
+                <div id={`${sectionId}-preset`} className={css.groupBody}>
+                  {selected.broken !== undefined ? (
+                    <p className={css.brokenNote} role="alert">{selected.broken}</p>
+                  ) : null}
+                  {selectedRows.length > 0 ? (
+                    <ul className={css.cards}>
+                      {selectedRows.map((row, index) => presetRowCard(selected, row, index))}
+                    </ul>
+                  ) : null}
+                  {otherMatchCount > 0 ? (
+                    <p className={css.hint}>
+                      {t('matchesInOtherPresets', { count: String(otherMatchCount) })}
+                      {otherPresetMatches.map(preset => (
+                        <button
+                          key={preset.id}
+                          type="button"
+                          className={css.jumpLink}
+                          onClick={() => { setChosenPreset(preset.id) }}
+                        >
+                          {preset.name ?? preset.id}
+                        </button>
+                      ))}
+                    </p>
+                  ) : null}
+                </div>
               ) : null}
             </section>
           ) : null}
@@ -422,36 +452,14 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
                   <span className={css.failedCount}>{filteredFailed.length} {t('failedCountLabel')}</span>
                 ) : null}
               </button>
-              {globalEffectiveOpen ? (
-                <div id={`${sectionId}-global`}>
-                  {filteredFailed.length + filteredRegular.length > 0 ? (
-                    <ul className={css.cards}>
-                      {filteredFailed.map(entry => globalRowCard(entry))}
-                      {filteredRegular.map(entry => globalRowCard(entry))}
-                    </ul>
-                  ) : null}
-                  {drawerEntries.length > 0 ? (
-                    <div className={css.drawer} data-plugin-drawer>
-                      <button
-                        type="button"
-                        className={css.groupToggle}
-                        aria-expanded={drawerEffectiveOpen}
-                        aria-controls={`${sectionId}-drawer`}
-                        onClick={() => { setDrawerOpen(!drawerEffectiveOpen) }}
-                      >
-                        <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
-                        <span className={css.groupTitle}>{t('drawerTitle')}</span>
-                        <span className={css.groupSubtitle}>{t('drawerSubtitle')}</span>
-                        <span className={css.groupCount}>{filteredDrawer.length}</span>
-                      </button>
-                      {drawerEffectiveOpen && filteredDrawer.length > 0 ? (
-                        <ul className={css.cards} id={`${sectionId}-drawer`}>
-                          {filteredDrawer.map(drawerRow => globalRowCard(drawerRow.entry, drawerRow.providers))}
-                        </ul>
-                      ) : null}
-                    </div>
-                  ) : null}
-                </div>
+              {globalEffectiveOpen && globalCount > 0 ? (
+                <ul className={css.cards} id={`${sectionId}-global`}>
+                  {filteredFailed.map(entry => globalRowCard(entry))}
+                  {filteredRegular.map(entry => globalRowCard(
+                    entry,
+                    entry.enabled ? undefined : enabledIn.get(entry.moduleName),
+                  ))}
+                </ul>
               ) : null}
             </section>
           ) : null}

+ 2 - 6
packages/client/ui-settings-plugin-inventory/src/client/locales.ts

@@ -15,9 +15,7 @@ export const zh = {
   presetOptionBroken: '{name}(加载失败)',
   globalTitle: '全局',
   globalSubtitle: '系统与所有会话共用',
-  drawerTitle: '会话插件',
-  drawerSubtitle: '不在全局运行,由 Agent 预设按会话提供',
-  drawerDetail: '全局已停用,由 Agent 预设按会话提供',
+  presetProvidedDetail: '全局已停用,由 Agent 预设按会话提供',
   enabledIn: '启用于',
   viewInPreset: '去预设分组查看',
   matchesInOtherPresets: '其他预设中还有 {count} 个匹配:',
@@ -58,9 +56,7 @@ export const en = {
   presetOptionBroken: '{name} (failed to load)',
   globalTitle: 'Global',
   globalSubtitle: 'Shared by the system and every session',
-  drawerTitle: 'Session plugins',
-  drawerSubtitle: 'Not running globally; agent presets provide them per session',
-  drawerDetail: 'Disabled globally; agent presets provide it per session',
+  presetProvidedDetail: 'Disabled globally; agent presets provide it per session',
   enabledIn: 'Enabled in',
   viewInPreset: 'View in the preset group',
   matchesInOtherPresets: '{count} more matches in other presets: ',

+ 52 - 25
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -76,20 +76,21 @@ async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise<ReturnType<ty
 
 const globalToggle = (): HTMLElement =>
   screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })
-const drawerToggle = (): HTMLElement =>
-  screen.getByRole('button', { name: (name: string) => name.startsWith(en.drawerTitle) })
 
 describe('PluginInventorySettingsTab', () => {
   it('shows the default preset first and keeps the global plane collapsed', async () => {
     const view = await renderReady()
 
-    const switcher = screen.getByRole('combobox', { name: en.switcherLabel })
-    expect((switcher as HTMLSelectElement).value).toBe('standard')
-    expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([
+    const switcher = screen.getByRole('button', { name: en.switcherLabel })
+    expect(switcher.textContent).toBe('标准模式 (default)')
+    fireEvent.click(switcher)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
       '标准模式 (default)',
       'ptc',
       '坏预设 (failed to load)',
     ])
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
     expect(screen.getByText(en.presetSubtitle)).toBeTruthy()
     expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6')
 
@@ -100,7 +101,8 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.disabledTag)).toBeTruthy()
     expect(screen.getByText(en.failedTag)).toBeTruthy()
     expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
-    expect(screen.getAllByRole('img', { name: 'Not running' })).toHaveLength(2)
+    // No live fiber, no dot: file-state rows carry only their enablement tag.
+    expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
 
     expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
     expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7')
@@ -126,9 +128,10 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
   })
 
-  it('expands the global plane with failures first and the session-plugin drawer', async () => {
+  it('expands the global plane with failures first and preset-provided rows inline', async () => {
     const view = await renderReady()
 
+    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
     fireEvent.click(globalToggle())
     expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
     const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]')
@@ -136,14 +139,11 @@ describe('PluginInventorySettingsTab', () => {
     // Failures float above the Loader-ordered remainder.
     expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed)
 
-    // The drawer stays collapsed until opened, then names its providers.
-    expect(drawerToggle().getAttribute('aria-expanded')).toBe('false')
-    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
-    fireEvent.click(drawerToggle())
+    // Rows the presets took over sit inline, marked instead of plainly disabled.
     expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2)
 
     fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' }))
-    expect(screen.getByText(en.drawerDetail)).toBeTruthy()
+    expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy()
     expect(screen.getByText(en.enabledIn)).toBeTruthy()
     expect(screen.getByText('标准模式 · ptc')).toBeTruthy()
 
@@ -151,41 +151,68 @@ describe('PluginInventorySettingsTab', () => {
     fireEvent.click(screen.getByRole('button', { name: 'telemetry, Failed' }))
     expect(screen.getByText('Failed to start')).toBeTruthy()
 
+    // An enabled entry with no live fiber says so in its details, dot-free.
+    fireEvent.click(screen.getByRole('button', { name: 'unobserved-name, Enabled' }))
+    expect(screen.getByText('Not running')).toBeTruthy()
+
     // A disabled row outside every preset stays plainly disabled.
     fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' }))
-    expect(screen.queryByText(en.drawerDetail)).toBeNull()
+    expect(screen.queryByText(en.presetProvidedDetail)).toBeNull()
 
-    fireEvent.click(drawerToggle())
-    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
     fireEvent.click(globalToggle())
     expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
   })
 
   it('switches the inspected preset in place, including broken ones', async () => {
     const view = await renderReady()
-    const switcher = screen.getByRole('combobox', { name: en.switcherLabel })
+    const pickPreset = (label: string): void => {
+      fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
+      fireEvent.click(screen.getByRole('menuitem', { name: label }))
+    }
 
-    fireEvent.change(switcher, { target: { value: 'ptc' } })
+    pickPreset('ptc')
     expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('3')
     fireEvent.click(screen.getAllByRole('button', { name: 'tool-bash, Enabled' })[0]!)
     // An unnamed preset labels provenance by its id.
     expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc')
 
-    fireEvent.change(switcher, { target: { value: 'shattered' } })
+    pickPreset('坏预设 (failed to load)')
     expect(screen.getByRole('alert').textContent).toBe('the composition file is missing')
     expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0')
   })
 
-  it('jumps from a drawer row to the preset that enables it', async () => {
+  it('collapses the preset group until a search forces it open', async () => {
+    const view = await renderReady()
+    const toggle = screen.getByRole('button', { name: en.presetSubtitle })
+
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    // The header keeps its count while the rows are folded away.
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6')
+    expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0)
+
+    fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } })
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText(en.conditionalTag)).toBeTruthy()
+
+    fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '' } })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+  })
+
+  it('jumps from a preset-provided row to the preset that enables it', async () => {
     await renderReady()
-    const switcher = screen.getByRole('combobox', { name: en.switcherLabel })
-    fireEvent.change(switcher, { target: { value: 'ptc' } })
+    fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' }))
 
     fireEvent.click(globalToggle())
-    fireEvent.click(drawerToggle())
     fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' }))
     fireEvent.click(screen.getByRole('button', { name: en.viewInPreset }))
-    expect((switcher as HTMLSelectElement).value).toBe('standard')
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent)
+      .toBe('标准模式 (default)')
   })
 
   it('searches across scopes and points at matches in other presets', async () => {
@@ -201,7 +228,7 @@ describe('PluginInventorySettingsTab', () => {
     const hint = screen.getByText((text: string) => text.startsWith('2 more matches'))
     expect(hint).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: 'ptc' }))
-    expect(screen.getByRole<HTMLSelectElement>('combobox', { name: en.switcherLabel }).value).toBe('ptc')
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('ptc')
 
     // A match visible only in another preset keeps the pointer without rows.
     fireEvent.change(search, { target: { value: 'crashy' } })
@@ -227,7 +254,7 @@ describe('PluginInventorySettingsTab', () => {
       ],
     } as unknown as Snapshot)
 
-    expect(screen.queryByRole('combobox', { name: en.switcherLabel })).toBeNull()
+    expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull()
     expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
     expect(screen.getAllByRole('listitem')).toHaveLength(2)
 

+ 8 - 0
packages/preset/agent-presets/src/mount.ts

@@ -57,6 +57,14 @@ const harnessBase = new WeakMap<object, string>()
 class PresetTree extends Include {
   constructor(ctx: Context, config: Include.Config) {
     super(ctx, config)
+    // EntryTree's constructor files every new tree under the nearest owning
+    // Loader entry's `subtree` slot — here the roster's own row, because the
+    // standing scope descends from the roster's fiber. Left in place, root
+    // `loader.entries()` would walk this composition as host entries (each
+    // preset overwriting the last), against the standing mount's contract of
+    // not being a Loader entry. Reclaim the slot.
+    const owner = this.ctx.fiber.entry
+    if (owner?.subtree === this) delete owner.subtree
     mounted.set(config, { tree: this, fiber: ctx.fiber })
   }
 

+ 32 - 0
packages/preset/agent-presets/tests/composition-inventory.spec.ts

@@ -289,6 +289,38 @@ describe('AgentPresets.compositionInventory', () => {
     expect(damaged?.broken).toContain('is missing')
   })
 
+  it('keeps a standing composition out of the root Loader entries', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    ctx.loader.builtins['agent-presets'] = AgentPresets
+    await ctx.plugin(LlmRuntime)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt, { persona: '' })
+    await ctx.plugin(ToolRuntime)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(AgentLoop, { agents: [] })
+    // The roster itself loads as a Loader entry, the way profiles mount it:
+    // the standing scope then descends from a fiber that OWNS an entry, which
+    // is exactly the shape that made EntryTree file the mount under it.
+    await ctx.loader.create({
+      name: 'cordis:agent-presets',
+      config: { default: 'standard', roots: [SYSTEM_ROOT], includeShippedRoot: false, includeUserRoot: false },
+    })
+    const before = [...ctx.loader.entries()].map(entry => entry.id)
+
+    await ctx.agents.create({
+      sessionId: SessionId('loader-entry-guard'),
+      setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
+    })
+
+    // The agent joined a standing composition without the composition
+    // becoming host Loader entries.
+    expect([...ctx.loader.entries()].map(entry => entry.id)).toEqual(before)
+  })
+
   it('reports a composition that raced discovery as broken instead of dropping it', async () => {
     const ctx = await harness({
       default: 'minimal',