소스 검색

feat(web): trim the plugin manager cards to name, one-liner, and switch

Every installed package, built-in packs included, is one card in one list:
a name, a one-liner, and its switch or Add to… menu, tagged only for a
pending restart, a problem, or being built-in; kind tags, running and off
tags, the two helper sentences, and the Installed subtitle are gone, and
the toolbar button reads Add. Uninstall and retry move into the expanded
card beside the version, the source, and a pack's components. The
session-plugin composition uses the same single-column cards, marks a
plugin the person installed as Local, and tags nothing else but a failed
row. Tags take the preset section's pill shape.
Yichen Jiang 1 개월 전
부모
커밋
a53fb1497d

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

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

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

@@ -16,7 +16,7 @@ The Host could manage a profile's plugins — install, enable, disable, retry, c
 
 **A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab after the configuration tab. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for a confirmation naming the dependents the Host reports. The install dialog also lists the packages the Host removed again after `pnpm add` — a package that is not a dsh package, a bundle whose row id another layer owns — with the Host's reason, and every action renders the two refusals any mutation can meet, `plugins/busy` and `plugins/agents-running`, in the Host's words.
 
-**The page speaks two nouns and four states.** A person who does not know the Loader sees *plugin packs* (switched as a whole, serving every session) and *plugins* (joining a preset or every session through **Add to…**), each card with a name, a one-liner, one of four states — running, off, restart needed, problem — and its uninstall beside the switch. Built-in packs fold behind one line. Harness modules in a preset read by the tab's own dictionary (`name.<row id or module slug>` / `desc.<…>`), third-party ones by their manifest title and description with a third-party mark. Entry ids, module names, cordis copies, probe times, and overridden rows stay off the page; the read-only **Plugin list** tab is gone with its package, `ui-settings-plugin-inventory`, while the `pluginInventory` Remote stays as the manager's source of preset compositions and host-tree module names.
+**The page speaks two nouns and shows neither.** A person who does not know the Loader sees *plugin packs* (switched as a whole, serving every session) and *plugins* (joining a preset or every session through **Add to…**), each card a name, a one-liner, and its switch or its **Add to…** menu; a card is tagged only for a pending restart or a problem, running and off being what the switch shows. Built-in packs sit in the same list with a *Built-in* mark and a locked switch; uninstall, retry, and a pack's components live in the expanded card. Harness modules in a preset read by the tab's own dictionary (`name.<row id or module slug>` / `desc.<…>`), installed ones by their manifest title and description with a *Local* mark. Entry ids, module names, cordis copies, probe times, and overridden rows stay off the page; the read-only **Plugin list** tab is gone with its package, `ui-settings-plugin-inventory`, while the `pluginInventory` Remote stays as the manager's source of preset compositions and host-tree module names.
 
 **The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
 

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

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

+ 7 - 12
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -23,22 +23,18 @@
     - tab "插件配置"
     - tab "插件管理" [selected]
   - tabpanel "插件管理":
-    - paragraph: 插件包整体启停;插件安装后可加入到某个预设。
     - button "刷新"
-    - button "添加插件"
+    - button "添加"
     - heading "已安装" [level=3]
     - text: 2 个
-    - paragraph: 对所有会话生效的插件包,以及可加入预设的插件
     - list:
       - listitem:
-        - text: "示例组合包 插件包 已停用 Web e2e fixture: a bundle whose one row is an inert plugin."
-        - button "卸载 示例组合包": 卸载
+        - text: "示例组合包 Web e2e fixture: a bundle whose one row is an inert plugin."
         - switch "启用 示例组合包"
         - button "展开 示例组合包":
           - img
       - listitem:
-        - text: "示例插件 插件 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
-        - button "卸载 示例插件": 卸载
+        - text: "示例插件 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
         - button "加入到…"
         - button "展开 示例插件":
           - img
@@ -46,7 +42,7 @@
     - button "选择要管理的 Agent 预设":
       - text: 标准模式(默认)
       - img
-    - paragraph: 这个预设的会话会用到的插件;改动只影响它
+    - paragraph: 这个预设的会话会用到的插件
     - list:
       - listitem:
         - text: 人设 助手的身份与语气
@@ -58,7 +54,7 @@
         - text: 终端 运行 shell 命令
         - switch "启用 终端" [checked]
       - listitem:
-        - text: PowerShell 终端 未启用 在 Windows 上运行 PowerShell
+        - text: PowerShell 终端 在 Windows 上运行 PowerShell
         - switch "启用 PowerShell 终端" [disabled]
       - listitem:
         - text: 文件读写 读取、创建和修改文件
@@ -106,10 +102,10 @@
         - text: 分支子代理 复制当前上下文继续任务
         - switch "启用 分支子代理" [checked]
       - listitem:
-        - text: Codex 子代理 未启用 把任务交给 Codex 执行
+        - text: Codex 子代理 把任务交给 Codex 执行
         - switch "启用 Codex 子代理" [disabled]
       - listitem:
-        - text: Claude Code 子代理 未启用 把任务交给 Claude Code 执行
+        - text: Claude Code 子代理 把任务交给 Claude Code 执行
         - switch "启用 Claude Code 子代理" [disabled]
       - listitem:
         - text: 工作流引擎 在后台线程运行工作流
@@ -129,4 +125,3 @@
       - listitem:
         - text: 网页 搜索和抓取网页
         - switch "启用 网页" [checked]
-    - paragraph: 改动即时保存;已经打开的会话继续用原来的组合,新会话生效。

+ 9 - 8
apps/web/tests/plugin-manager.e2e.ts

@@ -85,16 +85,18 @@ describe('web e2e: plugin manager', () => {
     await dialog.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
     expect(await dialog.getByText('示例插件', { exact: true }).count()).toBe(1)
     // The bundle is installed but not enabled; the plain plugin carries no
-    // switch, only its **Add to…** menu; both third-party packages offer uninstall.
+    // switch, only its **Add to…** menu; uninstall waits in the expanded card.
     const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' })
     expect(await toggle.getAttribute('aria-checked')).toBe('false')
     expect(await dialog.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0)
     expect(await dialog.getByRole('button', { name: '加入到…' }).count()).toBe(1)
-    expect(await dialog.getByRole('button', { name: '卸载 示例插件' }).count()).toBe(1)
-    // The scaffold profile lists no built-in pack, so nothing is folded; the
-    // default preset's composition renders beside the packages under the
+    expect(await dialog.getByRole('button', { name: '卸载 示例插件' }).count()).toBe(0)
+    await dialog.getByRole('button', { name: '展开 示例插件' }).click()
+    await dialog.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
+    await dialog.getByRole('button', { name: '收起 示例插件' }).click()
+    await expect.poll(() => dialog.getByRole('button', { name: '卸载 示例插件' }).count(), { timeout: 5_000 }).toBe(0)
+    // The default preset's composition renders beside the packages under the
     // harness dictionary's names.
-    expect(await dialog.locator('[data-builtin-count]').count()).toBe(0)
     expect(await dialog.getByRole('button', { name: '选择要管理的 Agent 预设' }).textContent()).toContain('默认')
     expect(await dialog.getByRole('switch', { name: '启用 终端' }).count()).toBe(1)
 
@@ -135,14 +137,14 @@ describe('web e2e: plugin manager', () => {
     await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(true)
     expect(await overlay()).toContain('id: fixture/plain-plugin')
     // The preset composition lists the new row under the package's title as
-    // the user's, switched on, with the third-party mark; the **Add to…** menu
+    // the user's, switched on, with the local mark; the **Add to…** menu
     // now marks that preset as joined.
     const presetRow = dialog.locator('[data-preset-row="fixture/plain-plugin"]')
     const rowToggle = presetRow.getByRole('switch', { name: '启用 示例插件' })
     await rowToggle.waitFor({ timeout: 10_000 })
     expect(await rowToggle.getAttribute('aria-checked')).toBe('true')
     expect(await presetRow.getAttribute('data-plugin-source')).toBe('user')
-    expect(await presetRow.getByText('第三方', { exact: true }).count()).toBe(1)
+    expect(await presetRow.getByText('本地', { exact: true }).count()).toBe(1)
     // Escape would close the settings dialog as well, so the menu closes
     // through its own anchor.
     await dialog.getByRole('button', { name: '加入到…' }).click()
@@ -153,7 +155,6 @@ describe('web e2e: plugin manager', () => {
     await rowToggle.click()
     await expect.poll(async () => (await overlay()).includes('disabled: true'), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false')
-    expect(await presetRow.getByText('已停用', { exact: true }).count()).toBe(1)
 
     await presetRow.getByRole('button', { name: '从这个预设移出 示例插件' }).click()
     await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(false)

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-manager/README.md
-README.md: 6e5102193285c81b73943dd4a1669d4ef694acc1
-README.zh.md: e4c9e210aec8caa50c83d0b628139b5698f175a7
+README.md: a9779df58f5baa3a7525b3c456d564648c313bcc
+README.zh.md: 3fd6ec9c82947e61ceec76f5fdbbe0ae27556db7

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per third-party package with its display name, its one-line description, a kind tag, and one of four states — running, off, restart needed, problem; a pack carries its switch, a plugin its **Add to…** menu, and both carry **Uninstall** beside it. An expanded pack shows its version, its source, and the components it contributes with their state; entry ids, module names, and probe facts stay off the page, and the built-in packs fold behind one line. The **Session plugins** group shows the selected preset's composition as a grid of cards, harness modules named through the tab's dictionary and third-party ones through their manifest with a third-party mark, with a switch per card and a remove action for rows the user added. The install dialog takes an npm name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
+`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns without showing them: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per package with its display name and its one-line description; a pack carries its switch, a plugin its **Add to…** menu, a built-in pack a *Built-in* mark and a locked switch, and a card is tagged only when a restart is pending or something is wrong. An expanded card shows the version, the source, a pack's components with their state, and **Uninstall**; entry ids, module names, kinds, and probe facts stay off the page. The **Session plugins** group shows the selected preset's composition as the same cards, harness modules named through the tab's dictionary and installed ones through their manifest with a *Local* mark, with a switch per card and **Remove** for rows the user added. The install dialog takes a package name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
 
 ## Table of Contents
 
@@ -29,15 +29,15 @@ Open the Plugins section in Settings and select the **Manage plugins** tab. The
 
 ### Installing a package
 
-**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a Git URL — and choose whether a newly installed pack is enabled right away. The dialog shows the run's progress with pnpm's output behind **Show install log**, which opens on its own when the run fails, and cannot be closed while the run is in flight. Once pnpm exits, one sentence per package says what happened: a pack installed and enabled, a pack installed and waiting for its switch, a plugin installed and ready to join a preset, a package that is not a DSH plugin removed again, or a pack removed again because its row id conflicts with an installed one — the last with the Host's reason. A run or any other action the Host refuses because another change is still running, or because a session is running, shows that refusal in the Host's words.
+**Add** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a Git URL — and choose whether a newly installed pack is enabled right away. The dialog shows the run's progress with pnpm's output behind **Show install log**, which opens on its own when the run fails, and cannot be closed while the run is in flight. Once pnpm exits, one sentence per package says what happened: a pack installed and enabled, a pack installed and waiting for its switch, a plugin installed and ready to join a preset, a package that is not a DSH plugin removed again, or a pack removed again because its row id conflicts with an installed one — the last with the Host's reason. A run or any other action the Host refuses because another change is still running, or because a session is running, shows that refusal in the Host's words.
 
 ### Switching a plugin pack
 
-A pack's switch calls `plugins.enable` or `plugins.disable`. On a live-reload profile the tree recomposes before the switch settles; a profile that applies layer changes at its next start reports the change as *restart needed*, and the tab names every such package in a banner until the restart. A pack the probe refused reads as a *problem* with the probe's reason in its expanded card and cannot be switched on; a pack whose components failed offers **Retry** beside its switch. Built-in packs are folded behind one line and, once shown, carry a locked switch. A disable first asks the Host what it would strand and skips the confirmation when nothing does.
+A pack's switch calls `plugins.enable` or `plugins.disable`. On a live-reload profile the tree recomposes before the switch settles; a profile that applies layer changes at its next start reports the change as *restart needed*, and the tab names every such package in a banner until the restart. A pack the probe refused reads as a *problem* with the probe's reason in its expanded card and cannot be switched on; a pack whose components failed offers **Retry** in its expanded card, beside **Uninstall**. A built-in pack carries a locked switch. A disable first asks the Host what it would strand and skips the confirmation when nothing does.
 
 ### Adding a plugin to a preset
 
-A plugin's card carries **Add to…**, listing every session and each preset; a target the plugin already joined is marked and disabled, and a package that declares several importable modules nests the targets under each module. The choice writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. The session group's preset switcher shows one preset's composition as cards: a card's switch writes `disabled: true` into that preset's user patch layer or removes the key again — a row the preset itself switched off stays locked, because the layer can only deny — and a row the user added carries **Remove** beside its switch. Rows that declare no id cannot be switched here.
+A plugin's card carries **Add to…**, listing every session and each preset; a target the plugin already joined is marked and disabled, and a package that declares several importable modules nests the targets under each module. The choice writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. The session group's preset switcher shows one preset's composition as cards: a card's switch writes `disabled: true` into that preset's user patch layer or removes the key again — a row the preset itself switched off stays locked, because the layer can only deny — and a row the user added carries **Remove** beside its switch. Running and off are what the switch shows; no card tags them. Rows that declare no id cannot be switched here.
 
 ### Reading a failure
 

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个第三方包列出一张卡片,带显示名、一句话描述、种类标签,以及四种状态之一——运行中、已停用、需重启、异常;插件包带开关,插件带**加入到…**菜单,两者都在旁边带**卸载**。展开的插件包显示版本、来源与它贡献的组件及各自状态;entry id、模块名与探针事实不出现在页面上,内置插件包折叠成一行。**会话插件**组把选中预设的组合显示成网格卡片,harness 模块按标签页字典命名,第三方模块按 manifest 命名并带第三方标记,每张卡片一个开关,用户添加的行还有移出操作。安装对话框接受 npm 包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
+`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词但不把它们显示出来:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个包列出一张卡片,带显示名与一句话描述;插件包带开关,插件带**加入到…**菜单,内置插件包带*内置*标记与锁定的开关,只有需重启或异常时卡片才打标签。展开的卡片显示版本、来源、插件包的组件及各自状态,以及**卸载**;entry id、模块名、种类与探针事实不出现在页面上。**会话插件**组把选中预设的组合显示成同样的卡片,harness 模块按标签页字典命名,自己装的模块按 manifest 命名并带*本地*标记,每张卡片一个开关,用户添加的行还有**移出**。安装对话框接受包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
 
 ## 目录
 
@@ -29,15 +29,15 @@ kind: "package-reference"
 
 ### 安装一个包
 
-**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、Git 地址——并选择新装的插件包是否立即启用。对话框显示运行进度,pnpm 输出收在**查看安装日志**折叠里,运行失败时自动展开;运行进行中对话框不能关闭。pnpm 退出后,每个包一句话说明结果:插件包已安装并启用、插件包已安装等待开关、插件已安装可加入预设、不是 DSH 插件的包已被移除、行 id 与已安装插件包冲突的包已被移除——最后一种附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
+**添加**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、Git 地址——并选择新装的插件包是否立即启用。对话框显示运行进度,pnpm 输出收在**查看安装日志**折叠里,运行失败时自动展开;运行进行中对话框不能关闭。pnpm 退出后,每个包一句话说明结果:插件包已安装并启用、插件包已安装等待开关、插件已安装可加入预设、不是 DSH 插件的包已被移除、行 id 与已安装插件包冲突的包已被移除——最后一种附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
 
 ### 切换一个插件包
 
-插件包的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为*需重启*,标签页会在横幅里点名每个这样的包直到重启。探针拒绝的插件包显示为*异常*,展开后可见探针的原因,且无法打开;组件启动失败的插件包在开关旁提供**重试**。内置插件包折叠成一行,展开后带锁定的开关。停用会先询问宿主它会搁置什么,没有依赖方时跳过确认。
+插件包的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为*需重启*,标签页会在横幅里点名每个这样的包直到重启。探针拒绝的插件包显示为*异常*,展开后可见探针的原因,且无法打开;组件启动失败的插件包在展开区提供**重试**,与**卸载**并排。内置插件包带锁定的开关。停用会先询问宿主它会搁置什么,没有依赖方时跳过确认。
 
 ### 把插件加入预设
 
-插件卡片带**加入到…**,列出所有会话与每个预设;已加入的目标被标注并置灰,声明了多个可导入模块的包把目标嵌在各模块之下。选择后通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。会话组的预设切换器把某个预设的组合显示成卡片:卡片的开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键——预设自己关掉的行保持锁定,因为该层只能拒绝——用户添加的行在开关旁带**移出**。没有声明 id 的行不能在这里切换。
+插件卡片带**加入到…**,列出所有会话与每个预设;已加入的目标被标注并置灰,声明了多个可导入模块的包把目标嵌在各模块之下。选择后通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。会话组的预设切换器把某个预设的组合显示成卡片:卡片的开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键——预设自己关掉的行保持锁定,因为该层只能拒绝——用户添加的行在开关旁带**移出**。运行中与已停用由开关表达,卡片不为此打标签。没有声明 id 的行不能在这里切换。
 
 ### 阅读失败
 

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

@@ -1,4 +1,4 @@
-/* Plugin manager tab: toolbar, notices, the package cards, and the preset composition grid. */
+/* Plugin manager tab: toolbar, notices, and the package and preset cards. */
 
 .section {
   display: flex;
@@ -12,23 +12,14 @@
 .toolbar {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-}
-
-.toolbarEnd {
-  display: inline-flex;
-  flex: none;
-  align-items: center;
+  justify-content: flex-end;
   gap: 8px;
 }
 
-.hint,
 .status,
 .failure p,
 .empty,
-.groupSub,
-.presetNote {
+.groupSub {
   margin: 0;
   font-size: 12.5px;
   line-height: 18px;
@@ -118,10 +109,6 @@
   margin: -4px 0 4px;
 }
 
-.presetNote {
-  margin-top: 4px;
-}
-
 .switcher {
   display: inline-flex;
   align-items: center;
@@ -145,7 +132,7 @@
 
 .switcher:focus-visible,
 .linkButton:focus-visible,
-.quietButton:focus-visible,
+.textButton:focus-visible,
 .addButton:focus-visible,
 .switch:focus-visible,
 .iconButton:focus-visible {
@@ -170,7 +157,6 @@
 }
 
 .cards,
-.presetCards,
 .parts {
   margin: 0;
   padding: 0;
@@ -183,21 +169,7 @@
   gap: 10px;
 }
 
-.presetCards {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  align-items: start;
-  gap: 10px;
-}
-
-@media (max-width: 640px) {
-  .presetCards {
-    grid-template-columns: minmax(0, 1fr);
-  }
-}
-
-.card,
-.presetCard {
+.card {
   min-width: 0;
   border: 0.5px solid var(--dsw-alias-border-l4);
   border-radius: 14px;
@@ -210,19 +182,13 @@
   box-shadow: var(--dsw-elevation-panel, none);
 }
 
-.cardHead,
-.presetCard {
+.cardHead {
   display: flex;
   align-items: center;
   gap: 12px;
   padding: 12px 14px;
 }
 
-.cardHead:hover:not(.cardHeadStatic) {
-  background: var(--dsw-alias-interactive-bg-hover);
-  border-radius: 14px;
-}
-
 .cardMain {
   flex: 1;
   min-width: 0;
@@ -257,32 +223,28 @@
   white-space: nowrap;
 }
 
+/* The pill the Agent preset section marks its cards with. */
 .tag {
   display: inline-flex;
   align-items: center;
-  min-height: 18px;
-  border-radius: 5px;
-  padding: 0 6px;
+  border-radius: 999px;
+  corner-shape: round;
+  padding: 1px 8px;
   background: var(--dsw-alias-bg-layer-1);
   color: var(--dsw-alias-label-secondary);
   font-size: 11px;
-  line-height: 16px;
+  line-height: 17px;
+  font-weight: 500;
   white-space: nowrap;
 }
 
-.tag[data-kind='kind'] {
+.tag[data-kind='builtin'] {
   background: transparent;
-  border: 0.5px solid var(--dsw-alias-border-l3);
+  border: 0.5px solid var(--dsw-alias-border-l4);
   color: var(--dsw-alias-label-tertiary);
 }
 
-.tag[data-kind='running'] {
-  background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
-  color: var(--dsw-alias-state-success-primary);
-}
-
-.tag[data-kind='disabled'],
-.tag[data-kind='muted'] {
+.tag[data-kind='off'] {
   color: var(--dsw-alias-label-tertiary);
 }
 
@@ -296,7 +258,7 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
-.tag[data-kind='external'] {
+.tag[data-kind='local'] {
   background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
   color: var(--dsw-alias-state-business-primary);
 }
@@ -371,7 +333,7 @@
   display: inline-flex;
 }
 
-.quietButton {
+.textButton {
   border: 0;
   border-radius: 8px;
   padding: 4px 8px;
@@ -383,20 +345,27 @@
   cursor: pointer;
 }
 
-.quietButton:hover:not(:disabled) {
+.textButton:hover:not(:disabled) {
   background: var(--dsw-alias-interactive-bg-hover);
   color: var(--dsw-alias-label-primary);
 }
 
-.quietButton[data-danger='true']:hover:not(:disabled) {
+.textButton[data-danger='true']:hover:not(:disabled),
+.actions .textButton[data-danger='true'] {
   color: var(--dsw-alias-state-error-primary);
 }
 
-.quietButton:disabled {
+.textButton:disabled {
   opacity: 0.5;
   cursor: default;
 }
 
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
 .addButton,
 .linkButton {
   border: 0.5px solid var(--dsw-alias-border-l3);
@@ -436,22 +405,6 @@
   white-space: nowrap;
 }
 
-.builtinNote {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 6px 10px;
-  margin: 4px 2px 0;
-  font-size: 12.5px;
-  line-height: 18px;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.builtinNote span {
-  flex: 1;
-  min-width: 0;
-}
-
 .cardBody {
   display: flex;
   flex-direction: column;

+ 93 - 152
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -1,11 +1,13 @@
 /**
  * The Manage plugins tab: the profile's installed packages as cards — a
- * plugin pack with its switch, a plugin with its **Add to…** menu, uninstall
- * beside the switch — with the built-in packs folded away; the selected
- * agent preset's composition as a grid of cards with one switch each; the
- * install dialog streaming pnpm's output behind a fold; and the confirmation
- * a destructive action waits on, naming what still uses the package. Entry
- * ids, module names, and probe facts stay off the page.
+ * plugin pack with its switch, a plugin with its **Add to…** menu, a built-in
+ * pack with a locked switch — each a name, a one-liner, and a tag only when
+ * a restart is pending or something is wrong; the selected agent preset's
+ * composition as the same cards with one switch each; the install dialog
+ * streaming pnpm's output behind a fold; and the confirmation a destructive
+ * action waits on, naming what still uses the package. Entry ids, module
+ * names, kinds, and probe facts stay off the page; an expanded card shows
+ * the version, the source, a pack's components, and its uninstall.
  */
 
 import { useEffect, useId, useState, type ReactNode } from 'react'
@@ -40,22 +42,20 @@ const PHASE_KEYS = {
   unloading: 'rowPhaseUnloading',
 } satisfies Record<RowPhase, PluginManagerLocaleKey>
 
-/** The four states a card shows; a `plain` package shows none. */
-type CardStatus = 'running' | 'disabled' | 'restart' | 'problem'
+/** The two states a card tags; running and off are what the switch shows. */
+type CardStatus = 'restart' | 'problem'
 
 const STATUS_OF = {
-  'running': 'running',
+  'running': null,
   'partial': 'problem',
   'failed': 'problem',
   'not-enableable': 'problem',
-  'disabled': 'disabled',
+  'disabled': null,
   'restart-required': 'restart',
   'plain': null,
 } satisfies Record<PluginPackageView['status'], CardStatus | null>
 
 const STATUS_KEYS = {
-  running: 'statusRunning',
-  disabled: 'statusDisabled',
   restart: 'statusRestart',
   problem: 'statusProblem',
 } satisfies Record<CardStatus, PluginManagerLocaleKey>
@@ -110,22 +110,22 @@ function rowLabel(t: Translate, entryId: string): string {
   return key === undefined ? id : t(key)
 }
 
-/** What one preset row shows: a title, a one-liner, and whether it is third-party. */
+/** What one preset row shows: a title, a one-liner, and whether the person installed it. */
 function presetRowCopy(
   row: PresetRow, packages: readonly PluginPackageView[], t: Translate,
-): { title: string; description?: string; external: boolean } {
+): { title: string; description?: string; local: boolean } {
   const pkg = packageOf(row.moduleName, packages)
   if (pkg !== undefined) {
     const addable = pkg.addable.find(entry => entry.moduleName === row.moduleName)
     return {
       title: addable?.title ?? pkg.title ?? shortName(pkg.name),
       ...pkg.description === undefined ? {} : { description: pkg.description },
-      external: pkg.trust === 'external',
+      local: pkg.trust === 'external',
     }
   }
   const harness = harnessCopy(t, row.moduleName, rowIdOf(row.entryId))
-  if (harness !== undefined) return { ...harness, external: false }
-  return { title: shortName(row.moduleName), description: row.moduleName, external: !row.moduleName.startsWith(FIRST_PARTY_SCOPE) }
+  if (harness !== undefined) return { ...harness, local: false }
+  return { title: shortName(row.moduleName), description: row.moduleName, local: !row.moduleName.startsWith(FIRST_PARTY_SCOPE) }
 }
 
 /** The roster row shown when the switcher has no explicit choice. */
@@ -173,8 +173,8 @@ function Switch({ checked, label, disabled, title, onChange }: {
   )
 }
 
-/** A quiet text action in a card head: grey until hovered. */
-function QuietButton({ label, disabled, danger, onClick, children }: {
+/** A text action: grey in a card head, red for a destructive one. */
+function TextButton({ label, disabled, danger, onClick, children }: {
   readonly label: string
   readonly disabled: boolean
   readonly danger?: boolean
@@ -184,7 +184,7 @@ function QuietButton({ label, disabled, danger, onClick, children }: {
   return (
     <button
       type="button"
-      className={css.quietButton}
+      className={css.textButton}
       data-danger={danger === true ? 'true' : undefined}
       aria-label={label}
       disabled={disabled}
@@ -221,7 +221,7 @@ function addTargets(
   ]
 }
 
-/** One managed package: its head with the switch or the add menu, and its details once expanded. */
+/** One installed package: its head with the switch or the add menu, and its details once expanded. */
 function PackageCard({
   pkg, t, busy, open, presets, globalModules, presetName, onToggleOpen, onSetEnabled, onRetry, onUninstall, onAddRow,
 }: {
@@ -241,8 +241,8 @@ function PackageCard({
   const [addMenu, setAddMenu] = useState(false)
   const title = pkg.title ?? shortName(pkg.name)
   const bundle = pkg.kind === 'bundle'
+  const builtin = pkg.trust === 'builtin'
   const status: CardStatus | null = bundle ? STATUS_OF[pkg.status] : pkg.reason === undefined ? null : 'problem'
-  const kindKey: PluginManagerLocaleKey = bundle ? 'kindBundle' : pkg.kind === 'plugin' ? 'kindPlugin' : 'kindLibrary'
   const detailId = `plugin-package-${encodeURIComponent(pkg.name)}`
   const addable = pkg.addable.filter(entry => entry.ok)
   const [single] = addable
@@ -254,6 +254,7 @@ function PackageCard({
       submenu: addTargets(entry, presets, globalModules, t, presetName),
     }))
   const retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
+  const removable = pkg.installed && !builtin
   return (
     <li
       className={css.card}
@@ -265,24 +266,19 @@ function PackageCard({
         <div className={css.cardMain}>
           <div className={css.titleRow}>
             <span className={css.cardTitle}>{title}</span>
-            <Tag kind="kind">{t(kindKey)}</Tag>
+            {builtin ? <Tag kind="builtin">{t('builtinTag')}</Tag> : null}
             {status === null ? null : <Tag kind={status}>{t(STATUS_KEYS[status])}</Tag>}
           </div>
           {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
         </div>
         <div className={css.cardEnd}>
-          {retryable
-            ? <QuietButton label={t('retryPackage')} disabled={busy} onClick={onRetry}>{t('retryPackage')}</QuietButton>
-            : null}
-          {pkg.installed
-            ? <QuietButton label={t('uninstallLabel', { name: title })} disabled={busy} danger onClick={onUninstall}>{t('uninstall')}</QuietButton>
-            : null}
           {bundle
             ? (
               <Switch
                 checked={pkg.enabled}
                 label={t('enableToggle', { name: title })}
-                disabled={busy || (!pkg.enabled && pkg.status === 'not-enableable')}
+                disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
+                {...builtin ? { title: t('builtinLocked') } : {}}
                 onChange={onSetEnabled}
               />
             )
@@ -335,7 +331,7 @@ function PackageCard({
             <dl className={css.facts}>
               {pkg.version === undefined ? null : <><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></>}
               <dt>{t('sourceLabel')}</dt>
-              <dd>{t('sourceExternal')}</dd>
+              <dd>{t(builtin ? 'sourceBuiltin' : 'sourceLocal')}</dd>
             </dl>
             {bundle
               ? (
@@ -351,7 +347,7 @@ function PackageCard({
                             <span className={css.partName}>{row.rowId}</span>
                             {row.enabled
                               ? null
-                              : <Tag kind="disabled">{t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')}</Tag>}
+                              : <Tag kind="off">{t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')}</Tag>}
                             {row.failure === undefined ? null : <p className={css.partFailure}>{row.failure.message}</p>}
                           </li>
                         ))}
@@ -360,6 +356,18 @@ function PackageCard({
                 </>
               )
               : null}
+            {retryable || removable
+              ? (
+                <div className={css.actions}>
+                  {retryable
+                    ? <TextButton label={t('retryPackage')} disabled={busy} onClick={onRetry}>{t('retryPackage')}</TextButton>
+                    : null}
+                  {removable
+                    ? <TextButton label={t('uninstallLabel', { name: title })} disabled={busy} danger onClick={onUninstall}>{t('uninstall')}</TextButton>
+                    : null}
+                </div>
+              )
+              : null}
           </div>
         )
         : null}
@@ -367,43 +375,6 @@ function PackageCard({
   )
 }
 
-/** One built-in package: shown on request, its pack switch locked, never expanded. */
-function BuiltinCard({ pkg, t }: { readonly pkg: PluginPackageView; readonly t: Translate }): ReactNode {
-  const title = pkg.title ?? shortName(pkg.name)
-  const status = pkg.kind === 'bundle' ? STATUS_OF[pkg.status] : null
-  return (
-    <li className={css.card} data-plugin-package={pkg.name} data-plugin-status={pkg.status}>
-      <div className={clsx(css.cardHead, css.cardHeadStatic)}>
-        <div className={css.cardMain}>
-          <div className={css.titleRow}>
-            <span className={css.cardTitle}>{title}</span>
-            <Tag kind="kind">{t(pkg.kind === 'bundle' ? 'kindBuiltinBundle' : pkg.kind === 'plugin' ? 'kindPlugin' : 'kindLibrary')}</Tag>
-            {status === null ? null : <Tag kind={status}>{t(STATUS_KEYS[status])}</Tag>}
-          </div>
-          <span className={css.cardDesc}>{pkg.description ?? pkg.name}</span>
-        </div>
-        {pkg.kind === 'bundle'
-          ? (
-            <div className={css.cardEnd}>
-              <button
-                type="button"
-                role="switch"
-                aria-checked={pkg.enabled}
-                aria-label={t('enableToggle', { name: title })}
-                title={t('builtinLocked')}
-                className={clsx(css.switch, pkg.enabled && css.switchOn)}
-                disabled
-              >
-                <span className={css.thumb} />
-              </button>
-            </div>
-          )
-          : null}
-      </div>
-    </li>
-  )
-}
-
 /** One row of the selected preset's composition, as a card with its switch. */
 function PresetCard({ preset, row, packages, t, busy, onSetDisabled, onRemove }: {
   readonly preset: PresetGroup
@@ -416,45 +387,40 @@ function PresetCard({ preset, row, packages, t, busy, onSetDisabled, onRemove }:
 }): ReactNode {
   const id = rowIdOf(row.entryId)
   const copy = presetRowCopy(row, packages, t)
-  const failed = row.fiberPhase === 'failed'
-  const off = row.enabled === false
-  const lockedOff = off && row.disabledBy === 'composition'
-  const stateTag = failed
-    ? <Tag kind="problem">{t('rowStateFailed')}</Tag>
-    : off
-      ? <Tag kind="disabled">{t(row.disabledBy === 'user' ? 'rowStateDisabledByUser' : 'rowStateDisabledByComposition')}</Tag>
-      : row.enabled === 'conditional' ? <Tag kind="muted">{t('rowStateConditional')}</Tag> : null
+  const lockedOff = row.enabled === false && row.disabledBy === 'composition'
   return (
-    <li className={css.presetCard} data-preset-row={row.entryId ?? undefined} data-plugin-source={row.source}>
-      <div className={css.cardMain}>
-        <div className={css.titleRow}>
-          <span className={css.cardTitle}>{copy.title}</span>
-          {copy.external ? <Tag kind="external">{t('thirdPartyTag')}</Tag> : null}
-          {stateTag}
+    <li className={css.card} data-preset-row={row.entryId ?? undefined} data-plugin-source={row.source}>
+      <div className={css.cardHead}>
+        <div className={css.cardMain}>
+          <div className={css.titleRow}>
+            <span className={css.cardTitle}>{copy.title}</span>
+            {copy.local ? <Tag kind="local">{t('localTag')}</Tag> : null}
+            {row.fiberPhase === 'failed' ? <Tag kind="problem">{t('rowStateFailed')}</Tag> : null}
+          </div>
+          {copy.description === undefined ? null : <span className={css.cardDesc}>{copy.description}</span>}
+        </div>
+        <div className={css.cardEnd}>
+          {id === null
+            ? <span className={css.rowNote} title={t('rowNoId')}>{t('rowNoId')}</span>
+            : (
+              <>
+                {row.source === 'user'
+                  ? (
+                    <TextButton label={t('rowRemoveLabel', { name: copy.title })} disabled={busy} danger onClick={() => { onRemove(id) }}>
+                      {t('rowRemove')}
+                    </TextButton>
+                  )
+                  : null}
+                <Switch
+                  checked={row.enabled !== false}
+                  label={t('rowToggle', { name: copy.title })}
+                  disabled={busy || preset.broken !== undefined || lockedOff}
+                  {...lockedOff ? { title: t('rowLockedByComposition') } : {}}
+                  onChange={(checked) => { onSetDisabled(id, !checked) }}
+                />
+              </>
+            )}
         </div>
-        {copy.description === undefined ? null : <span className={css.cardDesc}>{copy.description}</span>}
-      </div>
-      <div className={css.cardEnd}>
-        {id === null
-          ? <span className={css.rowNote} title={t('rowNoId')}>{t('rowNoId')}</span>
-          : (
-            <>
-              {row.source === 'user'
-                ? (
-                  <QuietButton label={t('rowRemoveLabel', { name: copy.title })} disabled={busy} danger onClick={() => { onRemove(id) }}>
-                    {t('rowRemove')}
-                  </QuietButton>
-                )
-                : null}
-              <Switch
-                checked={row.enabled !== false}
-                label={t('rowToggle', { name: copy.title })}
-                disabled={busy || preset.broken !== undefined || lockedOff}
-                {...lockedOff ? { title: t('rowLockedByComposition') } : {}}
-                onChange={(checked) => { onSetDisabled(id, !checked) }}
-              />
-            </>
-          )}
       </div>
     </li>
   )
@@ -629,25 +595,19 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
   const state = props.usePluginManager(snapshot => snapshot)
   const [expanded, setExpanded] = useState<string | null>(null)
   const [switcherOpen, setSwitcherOpen] = useState(false)
-  const [showBuiltin, setShowBuiltin] = useState(false)
   useEffect(() => { ensure() }, [ensure])
 
   const selected = state.presets.find(preset => preset.id === state.selectedPreset) ?? fallbackPreset(state.presets)
-  const managed = state.packages.filter(pkg => pkg.trust !== 'builtin')
-  const builtins = state.packages.filter(pkg => pkg.trust === 'builtin')
   const restartPending = state.packages.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
   const loaded = state.status === 'ready' || state.status === 'error'
 
   return (
     <div className={css.section} aria-busy={state.status === 'loading'}>
       <div className={css.toolbar}>
-        <p className={css.hint}>{t('hint')}</p>
-        <div className={css.toolbarEnd}>
-          <button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
-            <span className={css.iconWrap} aria-hidden="true"><IconRefreshOutline16 /></span>
-          </button>
-          <Button variant="primary" size="sm" disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
-        </div>
+        <button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
+          <span className={css.iconWrap} aria-hidden="true"><IconRefreshOutline16 /></span>
+        </button>
+        <Button variant="primary" size="sm" disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
       </div>
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
       {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
@@ -676,14 +636,13 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
             <section className={css.group} data-plugin-scope="global">
               <div className={css.groupTitleRow}>
                 <h3 className={css.groupTitle}>{t('installedTitle')}</h3>
-                <span className={css.count} data-plugin-count={managed.length}>{`${String(managed.length)} ${t('countUnit')}`}</span>
+                <span className={css.count} data-plugin-count={state.packages.length}>{`${String(state.packages.length)} ${t('countUnit')}`}</span>
               </div>
-              <p className={css.groupSub}>{t('installedSubtitle')}</p>
-              {managed.length === 0
+              {state.packages.length === 0
                 ? <p className={css.empty}>{t('empty')}</p>
                 : (
                   <ul className={css.cards}>
-                    {managed.map(pkg => (
+                    {state.packages.map(pkg => (
                       <PackageCard
                         key={pkg.name}
                         pkg={pkg}
@@ -702,21 +661,6 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
                     ))}
                   </ul>
                 )}
-              {builtins.length === 0
-                ? null
-                : (
-                  <>
-                    <p className={css.builtinNote} data-builtin-count={builtins.length}>
-                      <span>{t('builtinHidden', { count: String(builtins.length), names: builtins.map(pkg => pkg.title ?? shortName(pkg.name)).join(', ') })}</span>
-                      <button type="button" className={css.linkButton} aria-expanded={showBuiltin} onClick={() => { setShowBuiltin(current => !current) }}>
-                        {t(showBuiltin ? 'builtinHide' : 'builtinShow')}
-                      </button>
-                    </p>
-                    {showBuiltin
-                      ? <ul className={css.cards} data-plugin-builtins="">{builtins.map(pkg => <BuiltinCard key={pkg.name} pkg={pkg} t={t} />)}</ul>
-                      : null}
-                  </>
-                )}
             </section>
             <section className={css.group} data-plugin-scope="preset" data-preset-id={selected?.id}>
               <div className={css.groupTitleRow}>
@@ -759,23 +703,20 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
                   : selected.rows.length === 0
                     ? <p className={css.empty}>{t('presetRowsEmpty')}</p>
                     : (
-                      <>
-                        <ul className={css.presetCards}>
-                          {selected.rows.map((row, index) => (
-                            <PresetCard
-                              key={`${row.entryId ?? row.moduleName}:${String(index)}`}
-                              preset={selected}
-                              row={row}
-                              packages={state.packages}
-                              t={t}
-                              busy={row.entryId !== null && state.busy.includes(rowKey({ kind: 'preset', preset: selected.id }, rowIdOf(row.entryId) as string))}
-                              onSetDisabled={(rowId, disabled) => { props.setRowDisabled({ kind: 'preset', preset: selected.id }, rowId, disabled) }}
-                              onRemove={(rowId) => { props.removeRow({ kind: 'preset', preset: selected.id }, rowId) }}
-                            />
-                          ))}
-                        </ul>
-                        <p className={css.presetNote}>{t('presetNote')}</p>
-                      </>
+                      <ul className={css.cards}>
+                        {selected.rows.map((row, index) => (
+                          <PresetCard
+                            key={`${row.entryId ?? row.moduleName}:${String(index)}`}
+                            preset={selected}
+                            row={row}
+                            packages={state.packages}
+                            t={t}
+                            busy={row.entryId !== null && state.busy.includes(rowKey({ kind: 'preset', preset: selected.id }, rowIdOf(row.entryId) as string))}
+                            onSetDisabled={(rowId, disabled) => { props.setRowDisabled({ kind: 'preset', preset: selected.id }, rowId, disabled) }}
+                            onRemove={(rowId) => { props.removeRow({ kind: 'preset', preset: selected.id }, rowId) }}
+                          />
+                        ))}
+                      </ul>
                     )}
             </section>
           </>

+ 22 - 48
packages/client/ui-settings-plugin-manager/src/client/locales.ts

@@ -14,39 +14,30 @@ export const zh = {
   unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
   retry: '重试',
   refresh: '刷新',
-  hint: '插件包整体启停;插件安装后可加入到某个预设。',
   empty: '还没有安装任何插件。',
-  addPlugin: '添加插件',
+  addPlugin: '添加',
   restartBanner: '以下更改会在下次启动生效:{names}',
   restartNotice: '更改会在下次启动生效。',
   doneNotice: '已完成。',
   dismiss: '知道了',
   installedTitle: '已安装',
-  installedSubtitle: '对所有会话生效的插件包,以及可加入预设的插件',
   countUnit: '个',
-  builtinHidden: '已隐藏 {count} 个内置插件包({names}),它们随产品启动,不能在这里停用。',
-  builtinShow: '显示',
-  builtinHide: '收起',
   presetTitle: '会话插件',
-  presetSubtitle: '这个预设的会话会用到的插件;改动只影响它',
-  presetNote: '改动即时保存;已经打开的会话继续用原来的组合,新会话生效。',
+  presetSubtitle: '这个预设的会话会用到的插件',
   switcherLabel: '选择要管理的 Agent 预设',
   presetOptionDefault: '{name}(默认)',
   presetOptionBroken: '{name}(加载失败)',
   presetRowsEmpty: '该预设没有组合任何插件。',
   presetNoRoster: '本部署没有 Agent 预设。',
-  kindBundle: '插件包',
-  kindBuiltinBundle: '内置插件包',
-  kindPlugin: '插件',
-  kindLibrary: '库',
-  statusRunning: '运行中',
-  statusDisabled: '已停用',
+  builtinTag: '内置',
+  localTag: '本地',
   statusRestart: '需重启',
   statusProblem: '异常',
   reasonLabel: '原因',
   versionLabel: '版本',
   sourceLabel: '来源',
-  sourceExternal: '第三方',
+  sourceBuiltin: '内置',
+  sourceLocal: '本地',
   partsLabel: '包含的组件',
   partsEmpty: '这个插件包不包含任何组件。',
   partDisabledByUser: '已停用',
@@ -57,7 +48,7 @@ export const zh = {
   rowPhaseFailed: '异常',
   rowPhaseUnloading: '卸载中',
   enableToggle: '启用 {name}',
-  builtinLocked: '内置插件包随产品启动,不能在这里停用。',
+  builtinLocked: '内置功能随产品启动,不能在这里停用。',
   expand: '展开 {name}',
   collapse: '收起 {name}',
   addTo: '加入到…',
@@ -69,10 +60,6 @@ export const zh = {
   retryPackage: '重试',
   uninstall: '卸载',
   uninstallLabel: '卸载 {name}',
-  thirdPartyTag: '第三方',
-  rowStateDisabledByUser: '已停用',
-  rowStateDisabledByComposition: '未启用',
-  rowStateConditional: '按条件启用',
   rowStateFailed: '异常',
   rowToggle: '启用 {name}',
   rowLockedByComposition: '预设关闭了这个插件,不能在这里打开。',
@@ -80,7 +67,7 @@ export const zh = {
   rowRemoveLabel: '从这个预设移出 {name}',
   rowNoId: '这一行没有声明 id,无法在这里启停。',
   installTitle: '添加插件',
-  installDescription: '输入 npm 包名,也可以是本地目录或 Git 地址。安装完成后会自动检查它是不是 DSH 插件。',
+  installDescription: '输入插件的包名、本地路径或 Git 地址。',
   installSpecLabel: '包名或地址',
   installSpecPlaceholder: 'dsh-better-sidebar 或 /path/to/plugin',
   installExample: '例如 dsh-better-sidebar、github:someone/dsh-plugin、/path/to/plugin',
@@ -89,9 +76,9 @@ export const zh = {
   installRunning: '正在安装 {spec}…',
   installLogToggle: '查看安装日志',
   installLogLabel: '安装输出',
-  installDoneEnabled: '已安装并启用 {name}(插件包)。',
-  installDoneBundle: '已安装 {name}(插件包),打开它的开关即可启用。',
-  installDonePlugin: '已安装 {name}(插件),下一步可以加入到某个预设。',
+  installDoneEnabled: '已安装并启用 {name}。',
+  installDoneBundle: '已安装 {name},打开它的开关即可启用。',
+  installDonePlugin: '已安装 {name},可以加入到某个预设。',
   installDoneOther: '已安装 {name}。',
   installDoneNothing: '安装完成,没有新增依赖。',
   installRemovedLibrary: '{name} 不是 DSH 插件,已自动移除。',
@@ -203,39 +190,30 @@ export const en = {
   unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
   retry: 'Retry',
   refresh: 'Refresh',
-  hint: 'A plugin pack switches on and off as a whole; a plugin joins a preset after it is installed.',
   empty: 'No plugins are installed yet.',
-  addPlugin: 'Add plugin',
+  addPlugin: 'Add',
   restartBanner: 'These changes take effect at the next start: {names}',
   restartNotice: 'The change takes effect at the next start.',
   doneNotice: 'Done.',
   dismiss: 'Got it',
   installedTitle: 'Installed',
-  installedSubtitle: 'Plugin packs that serve every session, and plugins that can join a preset',
   countUnit: 'packages',
-  builtinHidden: '{count} built-in plugin pack(s) hidden ({names}); they start with the product and cannot be switched off here.',
-  builtinShow: 'Show',
-  builtinHide: 'Hide',
   presetTitle: 'Session plugins',
-  presetSubtitle: 'What sessions of this preset use; changes affect only this preset',
-  presetNote: 'Changes save at once; open sessions keep their composition, new sessions pick up the change.',
+  presetSubtitle: 'What sessions of this preset use',
   switcherLabel: 'Choose the agent preset to manage',
   presetOptionDefault: '{name} (default)',
   presetOptionBroken: '{name} (failed to load)',
   presetRowsEmpty: 'This preset composes no plugins.',
   presetNoRoster: 'This deployment has no agent presets.',
-  kindBundle: 'Plugin pack',
-  kindBuiltinBundle: 'Built-in plugin pack',
-  kindPlugin: 'Plugin',
-  kindLibrary: 'Library',
-  statusRunning: 'Running',
-  statusDisabled: 'Off',
+  builtinTag: 'Built-in',
+  localTag: 'Local',
   statusRestart: 'Restart needed',
   statusProblem: 'Problem',
   reasonLabel: 'Reason',
   versionLabel: 'Version',
   sourceLabel: 'Source',
-  sourceExternal: 'Third-party',
+  sourceBuiltin: 'Built-in',
+  sourceLocal: 'Local',
   partsLabel: 'Components',
   partsEmpty: 'This plugin pack contains no components.',
   partDisabledByUser: 'Off',
@@ -246,7 +224,7 @@ export const en = {
   rowPhaseFailed: 'Problem',
   rowPhaseUnloading: 'Unloading',
   enableToggle: 'Enable {name}',
-  builtinLocked: 'A built-in plugin pack starts with the product and cannot be switched off here.',
+  builtinLocked: 'A built-in feature starts with the product and cannot be switched off here.',
   expand: 'Show {name}',
   collapse: 'Hide {name}',
   addTo: 'Add to…',
@@ -258,10 +236,6 @@ export const en = {
   retryPackage: 'Retry',
   uninstall: 'Uninstall',
   uninstallLabel: 'Uninstall {name}',
-  thirdPartyTag: 'Third-party',
-  rowStateDisabledByUser: 'Off',
-  rowStateDisabledByComposition: 'Not enabled',
-  rowStateConditional: 'Conditional',
   rowStateFailed: 'Problem',
   rowToggle: 'Enable {name}',
   rowLockedByComposition: 'The preset switched this plugin off; it cannot be switched on here.',
@@ -269,7 +243,7 @@ export const en = {
   rowRemoveLabel: 'Remove {name} from this preset',
   rowNoId: 'This row declares no id, so it cannot be switched here.',
   installTitle: 'Add plugin',
-  installDescription: 'Enter an npm package name, a local directory, or a Git URL. After installing, it is checked to be a DSH plugin.',
+  installDescription: 'Enter the plugin\'s package name, a local path, or a Git URL.',
   installSpecLabel: 'Package or address',
   installSpecPlaceholder: 'dsh-better-sidebar or /path/to/plugin',
   installExample: 'For example dsh-better-sidebar, github:someone/dsh-plugin, /path/to/plugin',
@@ -278,9 +252,9 @@ export const en = {
   installRunning: 'Installing {spec}…',
   installLogToggle: 'Show install log',
   installLogLabel: 'Install output',
-  installDoneEnabled: 'Installed and enabled {name} (plugin pack).',
-  installDoneBundle: 'Installed {name} (plugin pack); turn its switch on to enable it.',
-  installDonePlugin: 'Installed {name} (plugin); next, add it to a preset.',
+  installDoneEnabled: 'Installed and enabled {name}.',
+  installDoneBundle: 'Installed {name}; turn its switch on to enable it.',
+  installDonePlugin: 'Installed {name}; it can join a preset.',
   installDoneOther: 'Installed {name}.',
   installDoneNothing: 'Install finished with no new dependency.',
   installRemovedLibrary: '{name} is not a DSH plugin and was removed again.',

+ 42 - 39
packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx

@@ -126,7 +126,7 @@ describe('PluginManagerSettingsTab', () => {
     expect(actions.openInstall).toHaveBeenCalledTimes(1)
   })
 
-  it('lists managed packages with kind and status tags, folds the built-in packs, and names what waits for a restart', () => {
+  it('lists every package as a card, tags only restarts, problems, and built-ins, and names what waits for a restart', () => {
     const { version: _unversioned, ...firstPartyPackage } = pkg({
       name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', trust: 'builtin', stage: 'boot',
     })
@@ -141,47 +141,44 @@ describe('PluginManagerSettingsTab', () => {
         pkg({ name: 'some-lib', kind: 'library', status: 'plain' }),
         pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }),
         pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required', installed: false }),
+        pkg({ name: 'off-bundle', enabled: false, status: 'disabled' }),
       ],
     })
     expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, untitled'))).toBeTruthy()
-    expect(document.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('6')
-    expect(screen.getAllByText(en.kindBundle)).toHaveLength(4)
-    expect(screen.getByText(en.kindPlugin)).toBeTruthy()
-    expect(screen.getByText(en.kindLibrary)).toBeTruthy()
-    expect(screen.getByText(en.statusRunning)).toBeTruthy()
+    expect(document.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('10')
+    // No kind tag, no running or off tag: the switch says that.
     expect(screen.getByText(en.statusProblem)).toBeTruthy()
     expect(screen.getAllByText(en.statusRestart)).toHaveLength(2)
+    expect(screen.getAllByText(en.builtinTag)).toHaveLength(3)
     expect(screen.getByText('A sidebar.')).toBeTruthy()
+    expect(document.querySelectorAll('[data-kind]')).toHaveLength(6)
 
     const sidebar = screen.getByRole('switch', { name: 'Enable better-sidebar' }) as HTMLButtonElement
     expect(sidebar.getAttribute('aria-checked')).toBe('true')
     fireEvent.click(sidebar)
     expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
     expect(screen.getByRole('switch', { name: 'Enable broken-bundle' })).toHaveProperty('disabled', true)
-    // A plugin carries no switch; a package that is not installed offers no uninstall.
-    expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull()
-    expect(screen.queryByRole('button', { name: 'Uninstall untitled' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Uninstall better-sidebar' }))
-    expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
-
-    // Built-in packages stay folded behind one line until asked for.
-    expect(document.querySelector('[data-builtin-count]')?.getAttribute('data-builtin-count')).toBe('3')
-    expect(screen.queryByRole('switch', { name: 'Enable First party' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: en.builtinShow }))
+    expect(screen.getByRole('switch', { name: 'Enable off-bundle' }).getAttribute('aria-checked')).toBe('false')
+    // A built-in pack keeps a locked switch in the same list; a built-in plugin or library has none.
     const firstParty = screen.getByRole('switch', { name: 'Enable First party' }) as HTMLButtonElement
     expect(firstParty.disabled).toBe(true)
     expect(firstParty.title).toBe(en.builtinLocked)
-    expect(screen.getByText(en.kindBuiltinBundle)).toBeTruthy()
-    // A built-in plugin module or library shows its package name in place of a description and no switch.
-    expect(screen.getAllByText('unknown')).toHaveLength(2)
     expect(screen.queryByRole('switch', { name: 'Enable unknown' })).toBeNull()
-    expect(screen.getAllByText(en.kindLibrary)).toHaveLength(2)
-    fireEvent.click(screen.getByRole('button', { name: en.builtinHide }))
-    expect(screen.queryByRole('switch', { name: 'Enable First party' })).toBeNull()
+    expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull()
+
+    // Uninstall lives in the expanded card, for installed packages the person added.
+    fireEvent.click(screen.getByRole('button', { name: 'Show better-sidebar' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Uninstall better-sidebar' }))
+    expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
+    fireEvent.click(screen.getByRole('button', { name: 'Show untitled' }))
+    expect(screen.queryByRole('button', { name: 'Uninstall untitled' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Show First party' }))
+    expect(document.querySelector('[data-plugin-package="@deepseek-ai/dsh-bundle-first-party"] dd')?.textContent).toBe(en.sourceBuiltin)
+    expect(screen.queryByRole('button', { name: 'Uninstall First party' })).toBeNull()
+    expect(screen.queryByText(en.versionLabel)).toBeNull()
   })
 
   it('expands a plugin pack into its facts and components, and retries a failing one', () => {
-    const { version: _unversioned, ...unversioned } = pkg({ name: 'no-version', enabled: false, status: 'disabled' })
     const { actions, set } = renderTab({
       packages: [
         pkg({
@@ -194,14 +191,15 @@ describe('PluginManagerSettingsTab', () => {
             { entryId: 'include:crash', rowId: 'crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } },
           ],
         }),
-        unversioned,
+        pkg({ name: 'no-rows', enabled: false, status: 'disabled' }),
         pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }),
+        pkg({ name: '@deepseek-ai/dsh-core-broken', title: 'Core', trust: 'builtin', status: 'failed' }),
       ],
     })
     fireEvent.click(screen.getByRole('button', { name: 'Show better-sidebar' }))
     expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
     expect(screen.getByText('0.16.0')).toBeTruthy()
-    expect(screen.getByText(en.sourceExternal)).toBeTruthy()
+    expect(screen.getByText(en.sourceLocal)).toBeTruthy()
     expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy()
     // A component shows the id its pack declared, not the tree-wide entry id.
     expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')?.textContent).toContain('better-sidebar')
@@ -215,20 +213,24 @@ describe('PluginManagerSettingsTab', () => {
     // A busy package keeps its controls inert.
     set({ busy: ['dsh-better-sidebar'] })
     expect(screen.getByRole('button', { name: en.retryPackage })).toHaveProperty('disabled', true)
+    expect(screen.getByRole('button', { name: 'Uninstall better-sidebar' })).toHaveProperty('disabled', true)
     expect(screen.getByRole('switch', { name: 'Enable better-sidebar' })).toHaveProperty('disabled', true)
     fireEvent.click(screen.getByRole('button', { name: 'Hide better-sidebar' }))
     expect(screen.queryByText(en.partsLabel)).toBeNull()
 
-    // A pack without components says so; one without a version shows no version.
-    fireEvent.click(screen.getByRole('button', { name: 'Show no-version' }))
+    // A pack without components says so; a plugin expands into its facts alone,
+    // and opening one card closes the other.
+    fireEvent.click(screen.getByRole('button', { name: 'Show no-rows' }))
     expect(screen.getByText(en.partsEmpty)).toBeTruthy()
-    expect(screen.queryByText(en.versionLabel)).toBeNull()
-    // A plugin expands into its facts alone.
     fireEvent.click(screen.getByRole('button', { name: 'Show tool-foo' }))
-    expect(screen.getByText(en.sourceExternal)).toBeTruthy()
+    expect(screen.getByText(en.sourceLocal)).toBeTruthy()
     expect(screen.queryByText(en.partsEmpty)).toBeNull()
-    // Only the pack with failing components offers a retry.
-    expect(screen.getAllByRole('button', { name: en.retryPackage })).toHaveLength(1)
+    // Only a pack with failing components offers a retry; a built-in one retries but never uninstalls.
+    expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Show Core' }))
+    fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
+    expect(actions.retry).toHaveBeenLastCalledWith('@deepseek-ai/dsh-core-broken')
+    expect(screen.queryByRole('button', { name: 'Uninstall Core' })).toBeNull()
   })
 
   it('offers Add to… for a plugin with importable modules, marking the targets it already joined', () => {
@@ -279,7 +281,7 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.queryByText('./c')).toBeNull()
   })
 
-  it('renders the selected preset composition as cards with harness names, third-party marks, switches, and removal', () => {
+  it('renders the selected preset composition as cards with harness names, local marks, switches, and removal', () => {
     const rows: PresetGroup['rows'] = [
       { entryId: 'include:agent-presets:persona', moduleName: '@deepseek-ai/dsh-persona', enabled: true, fiberPhase: 'active', source: 'preset' },
       { entryId: 'include:agent-presets:tool-subagent-fork', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null, source: 'preset' },
@@ -313,18 +315,19 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.getAllByText('Foo tool')).toHaveLength(2)
     expect(screen.getAllByText('A foo.')).toHaveLength(2)
     expect(screen.getByText('A tool')).toBeTruthy()
-    expect(screen.getAllByText(en.thirdPartyTag)).toHaveLength(5)
-    expect(screen.getByText(en.rowStateDisabledByUser)).toBeTruthy()
-    expect(screen.getByText(en.rowStateDisabledByComposition)).toBeTruthy()
-    expect(screen.getByText(en.rowStateConditional)).toBeTruthy()
+    // Only the local mark and the problem tag; off and conditional rows show through their switches.
+    expect(screen.getAllByText(en.localTag)).toHaveLength(5)
     expect(screen.getByText(en.rowStateFailed)).toBeTruthy()
+    expect(document.querySelectorAll('[data-preset-row] [data-kind]')).toHaveLength(6)
     expect(screen.getByText(en.rowNoId)).toBeTruthy()
-    expect(screen.getByText(en.presetNote)).toBeTruthy()
     expect(screen.queryByRole('switch', { name: `Enable ${en['name.tool-todo']}` })).toBeNull()
+    expect(screen.getByRole('switch', { name: `Enable ${en['name.tool-pwsh']}` }).getAttribute('aria-checked')).toBe('true')
 
     fireEvent.click(screen.getByRole('switch', { name: `Enable ${en['name.persona']}` }))
     expect(actions.setRowDisabled).toHaveBeenCalledWith(STANDARD_TARGET, 'persona', true)
-    fireEvent.click(screen.getByRole('switch', { name: 'Enable extra' }))
+    const extra = screen.getByRole('switch', { name: 'Enable extra' })
+    expect(extra.getAttribute('aria-checked')).toBe('false')
+    fireEvent.click(extra)
     expect(actions.setRowDisabled).toHaveBeenLastCalledWith(STANDARD_TARGET, 'extra', false)
     const gated = screen.getByRole('switch', { name: 'Enable gated' }) as HTMLButtonElement
     expect(gated.disabled).toBe(true)