浏览代码

feat(web): move plugin management to the sidebar's Plugins panel

Master's left sidebar grew global main panels, so the manager leaves the
Settings dialog: `ui-settings-plugin-manager` registers a `sidebar.panellist`
entry and the `main` panel it opens under one id, `plugins`, and the former
tab becomes `PluginManagerPage` with its own heading, intro, refresh, and
Add plugin, scrolling inside the main column. Plugin configuration stays in
the Settings Plugins section, which `ui-settings-plugins` now renders
without a tab strip when it holds one contribution.

Web e2e reaches the manager through the sidebar; the plugin-config,
plugin-manager, settings-chrome, and lifecycle-chrome scenarios and goldens
follow. The decision and why configuration does not move are in the new
Agent Note, cross-linked from the two notes it revises.
Yichen Jiang 1 周之前
父节点
当前提交
0aeebb017e
共有 37 个文件被更改,包括 366 次插入236 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
  8. 29 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
  9. 29 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
  10. 17 21
      apps/web/tests/expected/plugin-config/section.expected.md
  11. 18 41
      apps/web/tests/expected/plugin-manager/manager.expected.md
  12. 0 4
      apps/web/tests/plugin-config.e2e.ts
  13. 49 43
      apps/web/tests/plugin-manager.e2e.ts
  14. 10 11
      apps/web/tests/settings-chrome.e2e.ts
  15. 2 2
      packages/client/ui-settings-plugin-manager/README.i18n.yaml
  16. 12 12
      packages/client/ui-settings-plugin-manager/README.md
  17. 12 12
      packages/client/ui-settings-plugin-manager/README.zh.md
  18. 6 6
      packages/client/ui-settings-plugin-manager/package.json
  19. 1 1
      packages/client/ui-settings-plugin-manager/src/client/NoticeLine.tsx
  20. 31 3
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerPage.module.css
  21. 21 14
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerPage.tsx
  22. 15 0
      packages/client/ui-settings-plugin-manager/src/client/PluginsPanelIcon.tsx
  23. 1 1
      packages/client/ui-settings-plugin-manager/src/client/PresetPluginsSection.tsx
  24. 35 22
      packages/client/ui-settings-plugin-manager/src/client/index.ts
  25. 8 4
      packages/client/ui-settings-plugin-manager/src/client/locales.ts
  26. 18 10
      packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx
  27. 6 6
      packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
  28. 2 2
      packages/client/ui-settings-plugin-manager/tsconfig.json
  29. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  30. 1 1
      packages/client/ui-settings-plugins/README.md
  31. 1 1
      packages/client/ui-settings-plugins/README.zh.md
  32. 5 2
      packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx
  33. 8 0
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  34. 4 2
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  35. 5 5
      pnpm-lock.yaml
  36. 2 0
      snapshots/web/lifecycle-chrome/hero.expected.md
  37. 2 0
      snapshots/web/lifecycle-chrome/plan-active.expected.md

+ 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: 2b8112c3414e533dff2d8a18a52e2f9dbc5a2f35
-2026-09-04-plugin-management-in-web-settings.zh.md: bdb25c4a2b4e350fc472a452def69695e932dc75
+2026-09-04-plugin-management-in-web-settings.md: a13b3b45a566c1c6e2965ec69e758c3f3e293614
+2026-09-04-plugin-management-in-web-settings.zh.md: b3a7dc0fe5917a79462bfe0878c47bbc870b5ee2

文件差异内容过多而无法显示
+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md


文件差异内容过多而无法显示
+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.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-08-global-main-panels.md
-2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
-2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89
+2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2
+2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -12,7 +12,7 @@ Plugins need application-wide views that do not belong to a Session. A Session-s
 
 The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
 
-The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel.
 
 One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
 
-侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
 
 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
+2026-09-09-plugin-management-in-the-web-sidebar.md: 12bba6196fb13bfd90a13afd5488598533670e53
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 2255d006fe02f7430bcb445243f04cd95172880b

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md

@@ -0,0 +1,29 @@
+# Agent Note: Plugin management moves to the Web sidebar
+
+Status: implemented
+
+English | [中文](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)
+
+## Problem
+
+[Plugin management in Web settings](2026-09-04-plugin-management-in-web-settings.md) put the manager on a tab of the Settings dialog's Plugins section, beside the configuration tab. Settings is a modal over the current Session, while a person managing what is installed has no Session in mind; the dialog's width and its one-section-at-a-time shell also leave no room for a package page, an install run, and the list side by side. The layout has since gained root-scoped main panels behind sidebar entries ([global main panels](2026-09-08-global-main-panels.md)), which is the lifetime and the room the manager needs.
+
+## Decision
+
+**Management is a sidebar entry; configuration stays in Settings.** `ui-settings-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under one id, `plugins`: the sidebar renders the localized **Plugins** label, this package the icon and the page. The page is root-scoped and bound to no Session; it carries its own heading, intro, refresh, and **Add plugin**, and scrolls inside the main column. What the page shows did not change: the packs and plugins, each package's page, the install dialog, and the confirmations are the former tab's. The Settings **Plugins** section keeps plugin configuration, and `ui-settings-plugins` renders a single `settings.plugins.tab` contribution as the page itself, without a tab strip, so the section reads as the configuration page it now is. The preset detail page in Settings keeps its **Capabilities** section over the same store.
+
+**Configuration does not move.** A plugin's configuration binds a settings namespace under a scope — the global instance or one preset's — and the Settings shell owns that scope machinery, one selection serving the section and every preset's detail page. Moving the cards onto the management page would duplicate the shell's scope selection and split settings across two entry points, while a person looks for a value in Settings. The plugin's own page will instead point at its configurable sections once Settings can be opened on one section, which needs only a deep link.
+
+## Alternatives considered
+
+**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
+
+**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
+
+## Consequences
+
+The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section shows the configuration page without tabs. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
+
+## Testing
+
+`packages/client/ui-settings-plugin-manager/tests` pin the two registrations under one id and the page's rendering; `packages/client/ui-settings-plugins/tests` the tab-less single contribution; the web e2e scenarios above drive the panel and the section over a scaffold.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md

@@ -0,0 +1,29 @@
+# Agent Note:插件管理移到 Web 侧栏
+
+Status: implemented
+
+[English](2026-09-09-plugin-management-in-the-web-sidebar.md) | 中文
+
+## 问题
+
+[插件管理进入 Web 设置](2026-09-04-plugin-management-in-web-settings.zh.md)把管理器放在设置对话框「插件」分区的一个标签页上,与配置标签页并列。设置是盖在当前 Session 之上的模态框,而一个在管理已安装内容的人心里并没有 Session;对话框的宽度和一次只显示一个分区的外壳,也放不下包页面、安装运行与列表并排。布局此后有了侧栏入口背后的根级主面板([全局主面板](2026-09-08-global-main-panels.zh.md)),这正是管理器需要的生命周期与空间。
+
+## 决定
+
+**管理是侧栏入口;配置留在设置里。** `ui-settings-plugin-manager` 用同一个 id `plugins` 注册一个 `sidebar.panellist` 条目和它打开的 `main` 面板:侧栏渲染本地化的**插件**标签,本包提供图标与页面。页面是根级作用域,不绑定任何 Session;它带自己的标题、简介、刷新与**添加插件**,并在主列内部滚动。页面显示的内容没有变:插件包与插件、每个包的页面、安装对话框与各个确认框都来自原来的标签页。设置的**插件**分区保留插件配置,`ui-settings-plugins` 把唯一的 `settings.plugins.tab` 贡献直接渲染成这一页,没有标签条,因此该分区读起来就是它现在的样子——配置页。设置里的预设详情页保留读同一个 store 的**能力**段。
+
+**配置不搬。** 插件的配置把一个 settings 命名空间绑定在某个 scope 下——全局实例或某个预设——而设置外壳拥有这套 scope 机制,一处选择同时服务分区与每个预设的详情页。把卡片搬到管理页会复制外壳的 scope 选择,并把设置拆到两个入口,而人找一个值时会去设置里找。等设置能按分区打开后,插件自己的页面会转而指向它的可配置分区,那只需要一个深链接。
+
+## 考虑过的替代方案
+
+**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
+
+**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
+
+## 后果
+
+web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区显示没有标签页的配置页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+
+## 测试
+
+`packages/client/ui-settings-plugin-manager/tests` 钉住同一 id 下的两处注册与页面的渲染;`packages/client/ui-settings-plugins/tests` 钉住没有标签条的单一贡献;上述 web e2e 场景在脚手架上驱动面板与分区。

+ 17 - 21
apps/web/tests/expected/plugin-config/section.expected.md

@@ -19,24 +19,20 @@
     - text: 关闭
   - heading "插件" [level=2]
   - paragraph: 配置和查看本部署已安装的插件。
-  - tablist "插件视图":
-    - tab "插件管理"
-    - tab "插件配置" [selected]
-  - tabpanel "插件配置":
-    - list:
-      - listitem:
-        - 'button "展开设置: 终端"':
-          - text: 终端 限制 agent 运行的每一条命令。
-          - img
-      - listitem:
-        - 'button "展开设置: Agent 循环"':
-          - text: Agent 循环 Agent 如何派发工具调用。
-          - img
-      - listitem:
-        - 'button "展开设置: Subagent"':
-          - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
-          - img
-      - listitem:
-        - 'button "展开设置: 网页搜索"':
-          - text: 网页搜索 DeepSeek 搜索提供方。
-          - img
+  - list:
+    - listitem:
+      - 'button "展开设置: 终端"':
+        - text: 终端 限制 agent 运行的每一条命令。
+        - img
+    - listitem:
+      - 'button "展开设置: Agent 循环"':
+        - text: Agent 循环 Agent 如何派发工具调用。
+        - img
+    - listitem:
+      - 'button "展开设置: Subagent"':
+        - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
+        - img
+    - listitem:
+      - 'button "展开设置: 网页搜索"':
+        - text: 网页搜索 DeepSeek 搜索提供方。
+        - img

+ 18 - 41
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -1,43 +1,20 @@
-- dialog "设置":
-  - navigation:
-    - text: 设置
-    - button "通用设置":
+- heading "插件管理" [level=1]
+- paragraph: 管理已安装的插件。
+- button "刷新"
+- button "添加插件"
+- heading "插件包" [level=3]
+- text: 1 个
+- list:
+  - listitem:
+    - text: "示例组合包 Web e2e fixture: a bundle whose one row is an inert plugin."
+    - switch "启用 示例组合包"
+    - button "查看 示例组合包":
       - img
-      - text: 通用设置
-    - button "模型":
+- heading "插件" [level=3]
+- text: 1 个
+- list:
+  - listitem:
+    - text: "示例插件 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
+    - button "加入到…"
+    - button "查看 示例插件":
       - img
-      - text: 模型
-    - button "插件":
-      - img
-      - text: 插件
-    - button "Agent 预设":
-      - img
-      - text: Agent 预设
-  - button "打开配置文件"
-  - button "关闭":
-    - img
-    - text: 关闭
-  - heading "插件" [level=2]
-  - paragraph: 配置和查看本部署已安装的插件。
-  - tablist "插件视图":
-    - tab "插件管理" [selected]
-    - tab "插件配置"
-  - tabpanel "插件管理":
-    - button "刷新"
-    - button "添加"
-    - heading "插件包" [level=3]
-    - text: 1 个
-    - list:
-      - listitem:
-        - text: "示例组合包 Web e2e fixture: a bundle whose one row is an inert plugin."
-        - switch "启用 示例组合包"
-        - button "查看 示例组合包":
-          - img
-    - heading "插件" [level=3]
-    - text: 1 个
-    - list:
-      - listitem:
-        - text: "示例插件 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
-        - button "加入到…"
-        - button "查看 示例插件":
-          - img

+ 0 - 4
apps/web/tests/plugin-config.e2e.ts

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

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

@@ -1,4 +1,4 @@
-// Web e2e scenario: the plugin manager tab in Plugins settings over a scaffold
+// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a scaffold
 // profile runtime — one installed bundle switched on, one plain plugin added to
 // an agent preset and switched off there, and the configuration tab writing one
 // field under that preset's settings scope. Zero model calls: everything is
@@ -57,29 +57,37 @@ describe('web e2e: plugin manager', () => {
     await rm(presetRoot, { recursive: true, force: true })
   })
 
-  /** Open the settings dialog on the Plugins section and select one of its tabs. */
-  async function openPluginsTab(tab: string) {
+  /** Close any open settings dialog, so the sidebar and the main column are clickable. */
+  async function closeSettings() {
     if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
       await page.keyboard.press('Escape')
       await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
     }
+  }
+
+  /** Select the sidebar's Plugins entry and wait for the management page in the main column. */
+  async function openPluginsPanel() {
+    await closeSettings()
+    await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+    const panel = page.locator('[data-plugin-panel]')
+    await panel.getByRole('heading', { name: '插件管理' }).waitFor({ timeout: 10_000 })
+    return panel
+  }
+
+  /** Open the settings dialog on the Plugins section: the configuration page. */
+  async function openPluginsSettings() {
+    await closeSettings()
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.getByRole('button', { name: '插件', exact: true }).click()
-    await dialog.getByRole('tab', { name: tab, exact: true }).click()
-    await expect
-      .poll(() => dialog.getByRole('tab', { name: tab, exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
-      .toBe('true')
+    await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
     return dialog
   }
 
   /** Open the settings dialog on one preset's detail page. */
   async function openPresetDetail(name: string) {
-    if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
-      await page.keyboard.press('Escape')
-      await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
-    }
+    await closeSettings()
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
@@ -96,33 +104,33 @@ describe('web e2e: plugin manager', () => {
 
   it('lists the profile packages with their switches', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
-    const dialog = await openPluginsTab('插件管理')
+    const panel = await openPluginsPanel()
 
-    await dialog.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
-    expect(await dialog.getByText('示例插件', { exact: true }).count()).toBe(1)
+    await panel.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
+    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(1)
     // The bundle is installed but not enabled; the plain plugin carries no
     // switch, only its **Add to…** menu; uninstall waits on the plugin's page,
     // which names the module and where it is composed, and the crumb leads back.
-    const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' })
+    const toggle = panel.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(0)
-    await dialog.getByRole('button', { name: '查看 示例插件' }).click()
-    await dialog.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
-    await dialog.getByText('尚未加入', { exact: true }).waitFor({ timeout: 5_000 })
-    await dialog.getByRole('button', { name: '返回插件列表' }).click()
-    await expect.poll(() => dialog.getByRole('button', { name: '卸载 示例插件' }).count(), { timeout: 5_000 }).toBe(0)
-
-    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    expect(await panel.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0)
+    expect(await panel.getByRole('button', { name: '加入到…' }).count()).toBe(1)
+    expect(await panel.getByRole('button', { name: '卸载 示例插件' }).count()).toBe(0)
+    await panel.getByRole('button', { name: '查看 示例插件' }).click()
+    await panel.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
+    await panel.getByText('尚未加入', { exact: true }).waitFor({ timeout: 5_000 })
+    await panel.getByRole('button', { name: '返回插件列表' }).click()
+    await expect.poll(() => panel.getByRole('button', { name: '卸载 示例插件' }).count(), { timeout: 5_000 }).toBe(0)
+
+    const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('enables a bundle into the profile manifest and reports the restart it waits for', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
-    const dialog = await openPluginsTab('插件管理')
-    const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' })
+    const panel = await openPluginsPanel()
+    const toggle = panel.getByRole('switch', { name: '启用 示例组合包' })
     await toggle.waitFor({ timeout: 20_000 })
 
     await toggle.click()
@@ -136,39 +144,37 @@ describe('web e2e: plugin manager', () => {
     expect(manifest.dsh.profile.bundles).toEqual(['@fixture/bundle'])
     // A startup-applied profile: the switch is on, and the banner names the package.
     await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true')
-    await dialog.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 })
-    expect(await dialog.getByText('需重启', { exact: true }).count()).toBe(1)
+    await panel.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 })
+    expect(await panel.getByText('需重启', { exact: true }).count()).toBe(1)
     // The pack's page lists its rows from the probe. A profile that applies
     // patches at its next start keeps them read-only: no row switch.
-    await dialog.getByRole('button', { name: '查看 示例组合包' }).click()
-    await dialog.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
-    expect(await dialog.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
-    await dialog.getByRole('button', { name: '返回插件列表' }).click()
+    await panel.getByRole('button', { name: '查看 示例组合包' }).click()
+    await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
+    expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
+    await panel.getByRole('button', { name: '返回插件列表' }).click()
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('adds a plain plugin to a preset, then switches it off and deletes it on the preset\'s detail page', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preset-row'))
-    const dialog = await openPluginsTab('插件管理')
-    await dialog.getByRole('button', { name: '加入到…' }).click()
+    const panel = await openPluginsPanel()
+    await panel.getByRole('button', { name: '加入到…' }).click()
     await page.getByRole('menuitem', { name: '预设:标准模式' }).click()
 
     const overlay = (): Promise<string> => readFile(join(presetRoot, 'standard', 'cordis.patch.yml'), 'utf8').catch(() => '')
     await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(true)
     expect(await overlay()).toContain('id: fixture/plain-plugin')
-    // The **Add to…** menu now marks that preset as joined. Escape would
-    // close the settings dialog as well, so the menu closes through its anchor.
-    await dialog.getByRole('button', { name: '加入到…' }).click()
+    // The **Add to…** menu now marks that preset as joined; the menu closes
+    // through its anchor.
+    await panel.getByRole('button', { name: '加入到…' }).click()
     await page.getByRole('menuitem', { name: '预设:标准模式(已加入)' }).waitFor({ timeout: 5_000 })
-    await dialog.getByRole('button', { name: '加入到…' }).click()
+    await panel.getByRole('button', { name: '加入到…' }).click()
     await expect.poll(() => page.getByRole('menuitem').count(), { timeout: 5_000 }).toBe(0)
 
     // The preset's detail page, behind the gear on its card, lists the new
     // row under the package's title as the user's, switched on, with the
     // local mark.
-    await dialog.getByRole('button', { name: 'Agent 预设', exact: true }).click()
-    await dialog.getByRole('button', { name: '配置: 标准模式' }).click()
-    await dialog.getByRole('heading', { name: '标准模式' }).waitFor({ timeout: 10_000 })
+    const dialog = await openPresetDetail('标准模式')
     const presetRow = dialog.locator('[data-preset-row="fixture/plain-plugin"]')
     const rowToggle = presetRow.getByRole('switch', { name: '启用 示例插件' })
     await rowToggle.waitFor({ timeout: 10_000 })
@@ -210,7 +216,7 @@ describe('web e2e: plugin manager', () => {
     expect(document).toContain('preset/standard:')
     expect(document.indexOf('scopes:')).toBeLessThan(document.indexOf('timeoutMs: 12000'))
     // On the configuration tab the shared instance still shows the composed default.
-    const plugins = await openPluginsTab('插件配置')
+    const plugins = await openPluginsSettings()
     await plugins.getByRole('button', { name: '展开设置: 终端' }).click()
     await expect.poll(() => plugins.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('60000')
     expect(await plugins.getByText('已覆盖').count()).toBe(0)

+ 10 - 11
apps/web/tests/settings-chrome.e2e.ts

@@ -94,15 +94,13 @@ describe('web e2e: settings modal and General preferences', () => {
     await dialog.getByRole('button', { name: '模型' }).click()
     await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
-    // Plugins: the section opens on its configuration tab; the manager tab
-    // says in zh that this scaffold runs without a manageable profile (its
-    // own scenario file drives the page over a profile runtime).
+    // Plugins: the section is the configuration page, with no tab strip;
+    // management lives on the sidebar's Plugins panel (its own scenario file
+    // drives that page over a profile runtime).
     await dialog.getByRole('button', { name: '插件', exact: true }).click()
     await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
-    await dialog.getByRole('tab', { name: '插件管理', exact: true }).click()
-    await dialog.getByText('本部署没有可管理的 profile,无法安装或启停插件。', { exact: true }).waitFor({ timeout: 10_000 })
     expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
-    expect(await dialog.getByRole('tab', { name: '插件管理', exact: true }).getAttribute('aria-selected')).toBe('true')
+    expect(await dialog.getByRole('tab').count()).toBe(0)
     expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
     // Close path 1: Escape.
     await page.keyboard.press('Escape')
@@ -589,11 +587,12 @@ describe('web e2e: settings modal and General preferences', () => {
       const dialog = enPage.getByRole('dialog', { name: 'Settings' })
       await dialog.waitFor({ timeout: 10_000 })
       await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
-      // The plugin manager speaks the en dictionary too: its tab label and
-      // the unavailable notice a scaffold without a profile runtime shows.
-      await dialog.getByRole('button', { name: 'Plugins', exact: true }).click()
-      await dialog.getByRole('tab', { name: 'Manage plugins', exact: true }).click()
-      await dialog.getByText('This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', { exact: true })
+      // The plugin manager speaks the en dictionary too: its sidebar entry
+      // and the unavailable notice a scaffold without a profile runtime shows.
+      await enPage.keyboard.press('Escape')
+      await expect.poll(() => enPage.getByRole('dialog', { name: 'Settings' }).count(), { timeout: 5_000 }).toBe(0)
+      await enPage.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
+      await enPage.getByText('This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', { exact: true })
         .waitFor({ timeout: 10_000 })
       // This page has no closing inventory spec to sweep its console, so the
       // scenario clears both tripwire channels itself.

+ 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: 5fa4dd8c53ad13fcfe116b93fb1134e24bab1d08
-README.zh.md: 73ea85667fbec9ea428fc9c6def5bb7bf969af31
+README.md: 1feffda15c4db945541589d45c16feedb0ec7e34
+README.zh.md: fbc737ef8cd746d39c0114f697da72f6be8ea6ab

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

@@ -1,5 +1,5 @@
 ---
-description: "Plugin management tab in Web Plugins settings for the dsh web client: install packages through pnpm, enable and disable bundles, retry failed ones, and compose rows into the global user layer or one agent preset's."
+description: "Plugin management behind the Web sidebar's Plugins entry for the dsh web client: install packages through pnpm, enable and disable bundles, retry failed ones, and compose rows into the global user layer or one agent preset's."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use the **Manage plugins** tab in Web Plugins settings to see the profile's installed packages in two groups — plugin packs, switched on and off as a whole, and plugins, added to a preset or every session through **Add to…** — open a pack's own page to switch its rows and uninstall it, and install a package by name, path, or Git URL, watching each pnpm run in a terminal. Disabling a pack with dependents and every uninstall ask for confirmation. The same store contributes the **Capabilities** section to each preset's detail page.
+Use the **Plugins** entry in the Web sidebar to see the profile's installed packages in two groups — plugin packs, switched on and off as a whole, and plugins, added to a preset or every session through **Add to…** — open a pack's own page to switch its rows and uninstall it, and install a package by name, path, or Git URL, watching each pnpm run in a terminal. Dependents and every uninstall ask for confirmation. The same store contributes the **Capabilities** section to each preset's detail page; configuring a plugin stays in the Settings **Plugins** section.
 
 ## Table of Contents
 
@@ -25,15 +25,15 @@ Use the **Manage plugins** tab in Web Plugins settings to see the profile's inst
 <a id="use-this-package"></a>
 ## Use this package
 
-Open the Plugins section in Settings and select the **Manage plugins** tab. The tab reads no Remote during plugin activation — selecting it mounts the component, which then reads the packages and the preset compositions through `api-remotes`.
+Select **Plugins** in the sidebar. The page reads no Remote during plugin activation — selecting the entry mounts it, and it then reads the packages and the preset compositions through `api-remotes`. Configuring a plugin stays in the Settings **Plugins** section.
 
 ### Installing a package
 
-**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 as one terminal per pnpm command — the command line, pnpm's output streaming in with its colours, the exit code when it is not zero — and cannot be closed while the run is in flight. A line above the terminals names the profile directory pnpm installs into, and a finished run leaves **Done** as the only action until the spec changes, which starts the dialog over. 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 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 as one terminal per pnpm command — the command line, pnpm's output streaming in with its colours, the exit code when it is not zero — and cannot be closed while the run is in flight. A line above the terminals names the profile directory pnpm installs into, and a finished run leaves **Done** as the only action until the spec changes, which starts the dialog over. 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** in its expanded card, beside **Uninstall**. A built-in pack carries a locked switch. A disable first asks the Host what it would strand, with the switch inert meanwhile, and opens the confirmation only when something 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 page 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, with the switch inert meanwhile, and opens the confirmation only when something does.
 
 ### Switching one row of a pack
 
@@ -57,11 +57,11 @@ What the last action left to say sits above the groups: a restart notice, or the
 
 ### Registration
 
-The browser plugin registers one localized `settings.plugins.tab` contribution with id `manage` and order -10, before the configuration tab, and one `settings.agentPreset.detail` contribution with id `plugins` — the Capabilities section of every preset's detail page, over the same store. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries. Harness modules in a preset resolve their display name and one-liner through the tab's own dictionary — `name.<slug>` and `desc.<slug>`, tried by the composition row id first (four subagent rows share one module) and then by the unscoped module name without its `dsh-` prefix — and only for modules under the `@deepseek-ai/` scope; a third-party module reads its installed package's `dsh.title` and `description`, or an addable module's own title.
+The browser plugin registers the `plugins` panel under one id twice: a `sidebar.panellist` entry with order 0, whose localized label the sidebar renders and whose icon this package supplies, and the `main` keyed entry it opens — the management page, root-scoped and bound to no Session; plus one `settings.agentPreset.detail` contribution with id `plugins` — the Capabilities section of every preset's detail page, over the same store. Registration uses `ctx.slots.inject()`, so it follows late slot declaration, redeclaration, locale changes, and teardown without importing the sidebar or layout owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries. Harness modules in a preset resolve their display name and one-liner through the page's own dictionary — `name.<slug>` and `desc.<slug>`, tried by the composition row id first (four subagent rows share one module) and then by the unscoped module name without its `dsh-` prefix — and only for modules under the `@deepseek-ai/` scope; a third-party module reads its installed package's `dsh.title` and `description`, or an addable module's own title.
 
 ### The store
 
-`PluginManagerController` holds one snapshot: the read status, the packages, the preset groups, the busy keys, the notice, the install dialog, and the pending confirmation. `load` folds concurrent reads into one in-flight read plus one rerun, so an invalidation landing mid-read is never lost. Every action runs under a busy key — the package name, or `<target>:<rowId>` for a row — turns a refused answer into the notice with the Host's code and reason, and re-reads afterwards whatever happened. The plugin's `apply` subscribes `plugins/changed` and `connection/reset` to reload a tab that has rendered once, and `plugins/install-log` to fold chunks into the open install's runs by job, the `add` run and the removals that follow it alike, since the Host runs one mutation at a time.
+`PluginManagerController` holds one snapshot: the read status, the packages, the preset groups, the busy keys, the notice, the install dialog, and the pending confirmation. `load` folds concurrent reads into one in-flight read plus one rerun, so an invalidation landing mid-read is never lost. Every action runs under a busy key — the package name, or `<target>:<rowId>` for a row — turns a refused answer into the notice with the Host's code and reason, and re-reads afterwards whatever happened. The plugin's `apply` subscribes `plugins/changed` and `connection/reset` to reload a page that has rendered once, and `plugins/install-log` to fold chunks into the open install's runs by job, the `add` run and the removals that follow it alike, since the Host runs one mutation at a time.
 
 ### Confirmation
 
@@ -76,9 +76,9 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w
 
 These pages cover the settings section, the Remote calls, and the Host-side manager.
 
-- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section this tab registers into.
+- [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies.
 - [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `plugins.*` and `pluginInventory.list()`.
-- [plugin-manager](../../host/plugin-manager/README.md) — the Host-side manager this tab drives.
+- [plugin-manager](../../host/plugin-manager/README.md) — the Host-side manager this page drives.
 
 -----
 
@@ -99,9 +99,9 @@ None; this package neither assembles nor sends a provider request.
 These limits define the reach of the management view; they are current package constraints.
 
 - **Global user-layer rows are not listed per package** — a row added to every session marks that target in the package's **Add to…** menu and shows in its dependents, not on the card; removing it goes through the patch file or a future row list.
-- **Names for harness modules live in this tab's dictionary** — a new first-party agent-plane module reads by its short module name until `name.<slug>` and `desc.<slug>` are added here.
+- **Names for harness modules live in this page's dictionary** — a new first-party agent-plane module reads by its short module name until `name.<slug>` and `desc.<slug>` are added here.
 - **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish.
-- **No version picker** — the spec is typed as pnpm accepts it; the tab neither lists registry versions nor offers upgrades.
+- **No version picker** — the spec is typed as pnpm accepts it; the page neither lists registry versions nor offers upgrades.
 
 <a id="dev-note"></a>
 ### Dev Note
@@ -113,4 +113,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. This package owns a Settings contribution over Host-owned facts.
+**Runtime invariant:** No companion is published. This package owns a sidebar panel and a Settings contribution over Host-owned facts.

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

@@ -1,5 +1,5 @@
 ---
-description: "dsh Web 客户端设置里的插件管理标签页:经 pnpm 安装包、启停 bundle、重试失败的 bundle,并把行组合进全局用户层或某个 Agent 预设。"
+description: "dsh Web 客户端左侧栏「插件」入口背后的插件管理:经 pnpm 安装包、启停 bundle、重试失败的 bundle,并把行组合进全局用户层或某个 Agent 预设。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用 Web 插件设置里的**插件管理**标签页可以按两组查看 profile 已安装的包——插件包整体开关、插件经**加入到…**加入某个预设或所有会话——打开插件包自己的页面来开关它的行并卸载它,也可以按包名、路径或 Git 地址安装包,并在一块终端里观看每次 pnpm 运行。停用有依赖方的插件包与每次卸载都要确认。同一个 store 还把**能力**段贡献到每个预设的详情页。
+使用 Web 左侧栏的**插件**入口可以按两组查看 profile 已安装的包——插件包整体开关、插件经**加入到…**加入某个预设或所有会话——打开插件包自己的页面来开关它的行并卸载它,也可以按包名、路径或 Git 地址安装包,并在一块终端里观看每次 pnpm 运行。有依赖方时与每次卸载都要确认。同一个 store 还把**能力**段贡献到每个预设的详情页;插件的配置仍留在设置的**插件**分区
 
 ## 目录
 
@@ -25,15 +25,15 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-打开设置中的「插件」分区并选择**插件管理**标签页。插件激活期间不会读取 Remote——选择该标签页时才挂载组件,组件再通过 `api-remotes` 读取包与预设组合。
+在左侧栏选择**插件**。插件激活期间不会读取 Remote——选择该入口时才挂载页面,页面再通过 `api-remotes` 读取包与预设组合。插件的配置仍在设置的**插件**分区。
 
 ### 安装一个包
 
-**添加**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、Git 地址——并选择新装的插件包是否立即启用。对话框把每条 pnpm 命令显示成一块终端——命令行、带颜色边跑边刷的输出、非零时的退出码——运行进行中对话框不能关闭。终端上方一行写明 pnpm 安装到的 profile 目录;运行完成后只剩**完成**一个动作,改动包名后对话框从头开始。pnpm 退出后,每个包一句话说明结果:插件包已安装并启用、插件包已安装等待开关、插件已安装可加入预设、不是 DSH 插件的包已被移除、行 id 与已安装插件包冲突的包已被移除——最后一种附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
+**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、Git 地址——并选择新装的插件包是否立即启用。对话框把每条 pnpm 命令显示成一块终端——命令行、带颜色边跑边刷的输出、非零时的退出码——运行进行中对话框不能关闭。终端上方一行写明 pnpm 安装到的 profile 目录;运行完成后只剩**完成**一个动作,改动包名后对话框从头开始。pnpm 退出后,每个包一句话说明结果:插件包已安装并启用、插件包已安装等待开关、插件已安装可加入预设、不是 DSH 插件的包已被移除、行 id 与已安装插件包冲突的包已被移除——最后一种附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
 
 ### 切换一个插件包
 
-插件包的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为*需重启*,标签页会在横幅里点名每个这样的包直到重启。探针拒绝的插件包显示为*异常*,展开后可见探针的原因,且无法打开;组件启动失败的插件包在展开区提供**重试**,与**卸载**并排。内置插件包带锁定的开关。停用会先询问宿主它会搁置什么,询问期间开关置灰,只有存在依赖方时才弹出确认框。
+插件包的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为*需重启*,页会在横幅里点名每个这样的包直到重启。探针拒绝的插件包显示为*异常*,展开后可见探针的原因,且无法打开;组件启动失败的插件包在展开区提供**重试**,与**卸载**并排。内置插件包带锁定的开关。停用会先询问宿主它会搁置什么,询问期间开关置灰,只有存在依赖方时才弹出确认框。
 
 ### 切换插件包里的一行
 
@@ -57,11 +57,11 @@ kind: "package-reference"
 
 ### 注册
 
-浏览器插件注册一个 id 为 `manage`、order 为 -10 的本地化 `settings.plugins.tab` 贡献,位于配置标签页之前;另注册一个 id 为 `plugins` 的 `settings.agentPreset.detail` 贡献——每个预设详情页的能力段,读同一个 store。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。预设名经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。预设里的 harness 模块经标签页自己的字典解析显示名与一句话——`name.<slug>` 与 `desc.<slug>`,先按组合行 id 查(四个子代理行共用一个模块),再按去掉 `dsh-` 前缀的无 scope 模块名查——且只对 `@deepseek-ai/` scope 下的模块生效;第三方模块读取其安装包的 `dsh.title` 与 `description`,或可添加模块自己的标题。
+浏览器插件用同一个 id 把 `plugins` 面板注册两次:一个 order 为 0 的 `sidebar.panellist` 条目,其本地化标签由侧栏渲染、图标由本包提供,以及它打开的 `main` 键控条目——管理页面,根级作用域,不绑定任何 Session;另注册一个 id 为 `plugins` 的 `settings.agentPreset.detail` 贡献——每个预设详情页的能力段,读同一个 store。注册使用 `ctx.slots.inject()`,因此能跟随 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 侧栏或布局的拥有方。预设名经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。预设里的 harness 模块经页自己的字典解析显示名与一句话——`name.<slug>` 与 `desc.<slug>`,先按组合行 id 查(四个子代理行共用一个模块),再按去掉 `dsh-` 前缀的无 scope 模块名查——且只对 `@deepseek-ai/` scope 下的模块生效;第三方模块读取其安装包的 `dsh.title` 与 `description`,或可添加模块自己的标题。
 
 ### store
 
-`PluginManagerController` 持有一份快照:读取状态、包、预设组、忙碌键、提示、安装对话框与待确认项。`load` 把并发读取折叠成一次在途读取加一次重跑,因此读取中途到达的失效不会丢失。每个操作在一个忙碌键下运行——包名,或行的 `<target>:<rowId>`——把被拒的应答转成带宿主代码与原因的提示,并且无论结果如何随后重新读取。插件的 `apply` 订阅 `plugins/changed` 与 `connection/reset` 以重载渲染过的标签页,订阅 `plugins/install-log` 按 job 把块折进打开的安装的各次运行——`add` 运行与随后的移除运行一视同仁,因为宿主一次只跑一个变更。
+`PluginManagerController` 持有一份快照:读取状态、包、预设组、忙碌键、提示、安装对话框与待确认项。`load` 把并发读取折叠成一次在途读取加一次重跑,因此读取中途到达的失效不会丢失。每个操作在一个忙碌键下运行——包名,或行的 `<target>:<rowId>`——把被拒的应答转成带宿主代码与原因的提示,并且无论结果如何随后重新读取。插件的 `apply` 订阅 `plugins/changed` 与 `connection/reset` 以重载渲染过的页,订阅 `plugins/install-log` 按 job 把块折进打开的安装的各次运行——`add` 运行与随后的移除运行一视同仁,因为宿主一次只跑一个变更。
 
 ### 确认
 
@@ -76,9 +76,9 @@ kind: "package-reference"
 
 这些页面覆盖设置分区、Remote 调用与宿主侧管理器。
 
-- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——本标签页注册进的「插件」分区
+- [ui-sidebar](../ui-sidebar/README.zh.md)——「插件」入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面所占的 main slot
 - [api-remotes](../../api/remotes/README.zh.md)——`plugins.*` 与 `pluginInventory.list()` 背后的 Remote BFF 表面。
-- [plugin-manager](../../host/plugin-manager/README.zh.md)——本标签页驱动的宿主侧管理器。
+- [plugin-manager](../../host/plugin-manager/README.zh.md)——本页驱动的宿主侧管理器。
 
 -----
 
@@ -99,9 +99,9 @@ kind: "package-reference"
 这些限制界定管理视图的范围;它们是本包当前的约束。
 
 - **全局用户层的行不按包列出**——加入所有会话的行会在该包的**加入到…**菜单里标注该目标并出现在其依赖方里,而不在卡片上;移除它要走补丁文件或将来的行列表。
-- **harness 模块的名字放在本标签页的字典里**——新的第一方 agent 面模块在补上 `name.<slug>` 与 `desc.<slug>` 之前按短模块名显示。
+- **harness 模块的名字放在本页的字典里**——新的第一方 agent 面模块在补上 `name.<slug>` 与 `desc.<slug>` 之前按短模块名显示。
 - **一次只能安装一个**——对话框运行一条 pnpm 命令;第二个 spec 要等第一个结束。
-- **没有版本选择器**——spec 按 pnpm 接受的写法输入;标签页既不列出仓库版本也不提供升级。
+- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页既不列出仓库版本也不提供升级。
 
 <a id="dev-note"></a>
 ### 开发备注
@@ -113,4 +113,4 @@ kind: "package-reference"
 
 </details>
 
-**运行时不变量:** 不发布 companion。本包拥有一个基于宿主事实的设置贡献。
+**运行时不变量:** 不发布 companion。本包拥有一个侧栏面板与一个基于宿主事实的设置贡献。

+ 6 - 6
packages/client/ui-settings-plugin-manager/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-settings-plugin-manager",
-  "description": "Plugin management tab in Web Plugins settings: install, enable, disable, retry, and compose installed plugin packages",
+  "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages",
   "version": "0.1.5-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -29,10 +29,10 @@
     "client": {
       "inject": [
         "@deepseek-ai/dsh-api-remotes",
-        "@deepseek-ai/dsh-client-ui-settings",
-        "@deepseek-ai/dsh-client-ui-settings-plugins",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-ui-agent-preset"
+        "@deepseek-ai/dsh-client-ui-agent-preset",
+        "@deepseek-ai/dsh-client-ui-layout",
+        "@deepseek-ai/dsh-client-ui-sidebar"
       ],
       "platform": "web"
     }
@@ -53,10 +53,10 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-manager": "workspace:^",
     "@testing-library/react": "^16.1.0",

+ 1 - 1
packages/client/ui-settings-plugin-manager/src/client/NoticeLine.tsx

@@ -7,7 +7,7 @@ import type { ReactNode } from 'react'
 import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ManagerNotice } from './manager-store.ts'
 import { noticeText, type Translate } from './presentation.ts'
-import css from './PluginManagerSettingsTab.module.css'
+import css from './PluginManagerPage.module.css'
 
 /**
  * Render the last operation's notice with its dismiss button, or nothing while there is none.

+ 31 - 3
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css → packages/client/ui-settings-plugin-manager/src/client/PluginManagerPage.module.css

@@ -1,14 +1,42 @@
 /* Plugin manager tab: toolbar, notices, and the package and preset cards. */
 
-.section {
+.page {
   display: flex;
   flex-direction: column;
   gap: 14px;
-  width: 100%;
-  max-width: 760px;
+  box-sizing: border-box;
+  height: 100%;
+  overflow: auto;
+  padding: 24px 32px 40px;
   color: var(--dsw-alias-label-primary);
 }
 
+.page > * {
+  width: 100%;
+  max-width: 920px;
+}
+
+.pageHead {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.pageTitle {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+  line-height: 28px;
+}
+
+.pageIntro {
+  margin: 4px 0 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+}
+
 .toolbar {
   display: flex;
   align-items: center;

+ 21 - 14
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx → packages/client/ui-settings-plugin-manager/src/client/PluginManagerPage.tsx

@@ -1,5 +1,6 @@
 /**
- * The Manage plugins tab: the profile's installed packages as cards — a
+ * The plugin management page behind the sidebar's Plugins entry: the
+ * profile's installed packages as cards — a
  * plugin pack with its switch, a plugin with its **Add to…** menu, a built-in
  * pack with a locked switch — each a name, a one-liner, and a tag only when
  * a restart is pending or something is wrong; the install dialog streaming
@@ -24,11 +25,11 @@ import type { PluginManagerLocaleKey } from './locales.ts'
 import { rowKey, type ConfirmState, type InstallState, type PluginManagerFace, type PresetGroup } from './manager-store.ts'
 import { NoticeLine } from './NoticeLine.tsx'
 import { packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
-import css from './PluginManagerSettingsTab.module.css'
+import css from './PluginManagerPage.module.css'
 
-/** Full component props assembled by the Settings slot renderer. */
-export type PluginManagerSettingsTabProps =
-  PropsRuntime<'settings.plugins.tab'>
+/** Full component props assembled by the main slot renderer. */
+export type PluginManagerPageProps =
+  PropsRuntime<'main'>
   & PropsLocale<'settings.pluginManager'>
   & InjectFace<PluginManagerFace>
 
@@ -767,7 +768,7 @@ function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, o
 }
 
 /** Render the plugin manager: the installed packages, the install dialog, and the confirmation. */
-export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
+export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   const { t, presetName, ensure } = props
   const state = props.usePluginManager(snapshot => snapshot)
   // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards.
@@ -779,15 +780,21 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
   const openPkg = openPackage === null ? undefined : state.packages.find(pkg => pkg.name === openPackage)
 
   return (
-    <div className={css.section} aria-busy={state.status === 'loading'}>
+    <section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
       {openPkg === undefined
         ? (
-          <div className={css.toolbar}>
-            <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>
+          <header className={css.pageHead}>
+            <div>
+              <h1 className={css.pageTitle}>{t('title')}</h1>
+              <p className={css.pageIntro}>{t('intro')}</p>
+            </div>
+            <div className={css.toolbar}>
+              <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>
+          </header>
         )
         : null}
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
@@ -881,6 +888,6 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
             onCancel={props.cancelConfirm}
           />
         )}
-    </div>
+    </section>
   )
 }

+ 15 - 0
packages/client/ui-settings-plugin-manager/src/client/PluginsPanelIcon.tsx

@@ -0,0 +1,15 @@
+/** The sidebar's Plugins entry icon; the sidebar owns the button, label, and selected state around it. */
+
+import type { ReactNode } from 'react'
+import { IconCordisPluginOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+
+/**
+ * Render the plugin glyph at the size the sidebar asks for.
+ * @param props - the sidebar's icon share: the requested edge and whether the panel is selected.
+ * @returns the icon element.
+ */
+export function PluginsPanelIcon({ size }: PropsRuntime<'sidebar.panellist'>): ReactNode {
+  return <IconCordisPluginOutline14 size={size} />
+}

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

@@ -17,7 +17,7 @@ import type { PluginManagerFace, PresetGroup, PresetRow } from './manager-store.
 import { rowKey } from './manager-store.ts'
 import { NoticeLine } from './NoticeLine.tsx'
 import { presetRowCopy, rowIdOf, shortName, type Translate } from './presentation.ts'
-import css from './PluginManagerSettingsTab.module.css'
+import css from './PluginManagerPage.module.css'
 
 /** Full component props assembled by the slot renderer. */
 export type PresetPluginsSectionProps =

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

@@ -1,17 +1,19 @@
 /**
- * Plugin manager, browser half: the **Manage plugins** tab of the Plugins
- * settings section, and the capabilities section of every agent preset's
- * detail page. The tab installs, enables, disables, retries, and uninstalls
- * the packages of the Host's profile through the `plugins` Remote; the
- * section composes rows into one preset's user layer.
+ * Plugin manager, browser half: the **Plugins** entry of the sidebar and the
+ * management page it opens in the main column, and the capabilities section
+ * of every agent preset's detail page. The page installs, enables, disables,
+ * retries, and uninstalls the packages of the Host's profile through the
+ * `plugins` Remote; the section composes rows into one preset's user layer.
+ * Configuring a plugin stays in Settings.
  */
 
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
-// Type-only: the `settings.plugins.tab` slot this tab registers into is
-// declared by ui-settings-plugins; registration goes through `slots.inject`.
-import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
+// Type-only: the root `main` keyed slot the page registers into, declared by
+// ui-layout with the panel id brand, and the `sidebar.panellist` list the
+// entry registers into, declared by ui-sidebar.
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 // Type-only: the ctx.remote Context merge and the forwarded-event key face.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -24,12 +26,13 @@ import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
 import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
 // Inline-safe shared fold: shipped ids map to dictionary keys in one home.
 import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
-import { PluginManagerSettingsTab } from './PluginManagerSettingsTab.tsx'
+import { PluginManagerPage } from './PluginManagerPage.tsx'
+import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
 import { PresetPluginsSection } from './PresetPluginsSection.tsx'
 import { PluginManagerController } from './manager-store.ts'
 import { en, zh, type PluginManagerLocaleKey } from './locales.ts'
 
-export type { PluginManagerSettingsTabProps } from './PluginManagerSettingsTab.tsx'
+export type { PluginManagerPageProps } from './PluginManagerPage.tsx'
 export type { PresetPluginsSectionProps } from './PresetPluginsSection.tsx'
 export type {
   ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PluginManagerState, PresetGroup, PresetRow,
@@ -46,13 +49,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Dictionary namespace owned by this plugin. */
 export const NS = 'settings.pluginManager'
 
+/** The id shared by the sidebar entry and the main panel it opens. */
+export const PANEL_ID = 'plugins' as MainPanelId
+
 /** Services required by the Settings registration and the two Remote faces. */
 export const inject = ['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory']
 
 /**
- * Contribute the manager tab to the Plugins settings section and the
- * capabilities section to every preset's detail page, and keep both current
- * on the Host's change events.
+ * Contribute the Plugins entry to the sidebar with the management page it
+ * opens, and the capabilities section to every preset's detail page, and
+ * keep both current on the Host's change events.
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
@@ -64,7 +70,7 @@ export function apply(ctx: ClientContext): void {
   // The Host says when what is installed, enabled, or composed changed — from
   // this page, the CLI, or another browser — and streams install output.
   ctx.effect(() => {
-    // A tab never rendered holds no snapshot to refresh.
+    // A page never rendered holds no snapshot to refresh.
     const refresh = (): void => {
       if (controller.getSnapshot().status !== 'idle') void controller.load()
     }
@@ -76,15 +82,22 @@ export function apply(ctx: ClientContext): void {
     return () => { for (const dispose of disposers) dispose() }
   }, 'ui-settings-plugin-manager: host invalidations')
 
-  // Before the configuration tab: what is installed comes before how it is configured.
-  ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
-    name: 'settings.plugins.tab',
-    id: 'manage',
-    order: -10,
-    label: () => t('tab'),
+  // The page is a global panel: it belongs to the profile, not to a Session,
+  // and the sidebar's entry selects it. How a plugin is configured stays in
+  // Settings; this page is what is installed and switched on.
+  ctx.slots.inject('main', () => ctx.slots.register({
+    name: 'main',
+    key: PANEL_ID,
     locale: NS,
     inject: () => controller.inject(),
-  }, PluginManagerSettingsTab))
+  }, PluginManagerPage))
+  ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
+    name: 'sidebar.panellist',
+    id: PANEL_ID,
+    order: 0,
+    label: () => t('panel'),
+    locale: NS,
+  }, PluginsPanelIcon))
 
   ctx.slots.inject('settings.agentPreset.detail', () => ctx.slots.register({
     name: 'settings.agentPreset.detail',

+ 8 - 4
packages/client/ui-settings-plugin-manager/src/client/locales.ts

@@ -8,14 +8,16 @@
 
 /** Simplified Chinese dictionary and key source of truth. */
 export const zh = {
-  tab: '插件管理',
+  panel: '插件',
+  title: '插件管理',
+  intro: '管理已安装的插件。',
   loading: '正在读取插件…',
   error: '暂时无法读取插件。',
   unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
   retry: '重试',
   refresh: '刷新',
   empty: '还没有安装任何插件。',
-  addPlugin: '添加',
+  addPlugin: '添加插件',
   restartBanner: '以下更改会在下次启动生效:{names}',
   restartNotice: '更改会在下次启动生效。',
   dismiss: '知道了',
@@ -221,14 +223,16 @@ export type PluginManagerLocaleKey = keyof typeof zh
 
 /** English dictionary checked against the Chinese key set. */
 export const en = {
-  tab: 'Manage plugins',
+  panel: 'Plugins',
+  title: 'Manage plugins',
+  intro: 'Manage the installed plugins.',
   loading: 'Reading plugins…',
   error: 'Plugins are temporarily unavailable.',
   unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
   retry: 'Retry',
   refresh: 'Refresh',
   empty: 'No plugins are installed yet.',
-  addPlugin: 'Add',
+  addPlugin: 'Add plugin',
   restartBanner: 'These changes take effect at the next start: {names}',
   restartNotice: 'The change takes effect at the next start.',
   dismiss: 'Got it',

+ 18 - 10
packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -6,8 +6,9 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply, inject, NS } from '../src/client/index.ts'
-import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
+import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
+import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
+import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
 import { PresetPluginsSection } from '../src/client/PresetPluginsSection.tsx'
 import type { PluginManagerFace } from '../src/client/manager-store.ts'
 import { apply as hostApply } from '../src/index.ts'
@@ -39,7 +40,8 @@ function declare(slots: SlotRegistry): () => void {
   return slots.register({
     name: 'root',
     children: {
-      'settings.plugins.tab': { kind: 'list', scope: 'root' },
+      'main': { kind: 'keyed', scope: 'root' },
+      'sidebar.panellist': { kind: 'list', scope: 'root' },
       'settings.agentPreset.detail': { kind: 'list', scope: 'root' },
     },
   } as never, () => null)
@@ -50,21 +52,26 @@ describe('ui-settings-plugin-manager browser plugin', () => {
     expect(hostApply).not.toThrow()
   })
 
-  it('declares only the services the tab and its two Remote faces use', () => {
+  it('declares only the services the page and its two Remote faces use', () => {
     expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory'])
   })
 
-  it('registers a localized tab that reads the Host only once rendered, and follows Host changes', async () => {
+  it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
     const b = await bench()
     declare(b.slots)
     const fiber = b.ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
 
-    const entry = b.slots.entries('settings.plugins.tab')[0]!
-    expect(entry.component).toBe(PluginManagerSettingsTab)
-    expect(entry.options).toMatchObject({ id: 'manage', order: -10 })
+    const entry = b.slots.entries('main')[0]!
+    expect(entry.component).toBe(PluginManagerPage)
+    expect(entry.options).toMatchObject({ key: PANEL_ID })
     expect(entry.locale).toBe(NS)
-    expect(resolveSlotLabel(entry.options.label)).toBe('插件管理')
+    // The sidebar entry addresses the page by the same id and speaks the dictionary.
+    const icon = b.slots.entries('sidebar.panellist')[0]!
+    expect(icon.component).toBe(PluginsPanelIcon)
+    expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
+    expect(icon.locale).toBe(NS)
+    expect(resolveSlotLabel(icon.options.label)).toBe('插件')
     // The same store feeds the capabilities section of every preset's detail page.
     const section = b.slots.entries('settings.agentPreset.detail')[0]!
     expect(section.component).toBe(PresetPluginsSection)
@@ -101,7 +108,8 @@ describe('ui-settings-plugin-manager browser plugin', () => {
     expect(face.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的')
 
     await fiber.dispose()
-    expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+    expect(b.slots.entries('main')).toHaveLength(0)
+    expect(b.slots.entries('sidebar.panellist')).toHaveLength(0)
     expect(b.slots.entries('settings.agentPreset.detail')).toHaveLength(0)
     b.remote.emit('plugins/changed', [{ reason: 'install' }])
     await Promise.resolve()

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

@@ -4,8 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
-import type { PluginManagerSettingsTabProps } from '../src/client/PluginManagerSettingsTab.tsx'
+import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
+import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx'
 import { rowKey, type InstallState, type PluginManagerState, type PresetGroup } from '../src/client/manager-store.ts'
 import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
 
@@ -15,7 +15,7 @@ const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): strin
   Object.entries(params ?? {}).reduce(
     (text, [name, value]) => text.replaceAll(`{${name}}`, value),
     en[key],
-  )) as PluginManagerSettingsTabProps['t']
+  )) as PluginManagerPageProps['t']
 
 function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
   return {
@@ -81,8 +81,8 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     ...actions,
     presetName: (candidate: PresetGroup) => candidate.name ?? candidate.id,
     usePluginManager: bindSnapshotSelector(store),
-  } as unknown as PluginManagerSettingsTabProps
-  render(<PluginManagerSettingsTab {...props} />)
+  } as unknown as PluginManagerPageProps
+  render(<PluginManagerPage {...props} />)
   return { store, actions, set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } }
 }
 
@@ -98,7 +98,7 @@ describe('locale dictionaries', () => {
   })
 })
 
-describe('PluginManagerSettingsTab', () => {
+describe('PluginManagerPage', () => {
   it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
     const { actions, set } = renderTab({ status: 'loading' })
     expect(actions.ensure).toHaveBeenCalledTimes(1)

+ 2 - 2
packages/client/ui-settings-plugin-manager/tsconfig.json

@@ -16,10 +16,10 @@
     { "path": "../locale" },
     { "path": "../store" },
     { "path": "../ui-agent-preset" },
+    { "path": "../ui-layout" },
     { "path": "../ui-primitives" },
     { "path": "../ui-renderer" },
-    { "path": "../ui-settings" },
-    { "path": "../ui-settings-plugins" },
+    { "path": "../ui-sidebar" },
     { "path": "../ui-slots" }
   ]
 }

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
-README.md: 2943abc13c5614779393630098ae4d332bab80f4
-README.zh.md: 3d5fb24743089769bf8d36b43e764fef64c55d41
+README.md: 60508bbd2701f151d03ace026da8957c5a35bda4
+README.zh.md: aa6a8f0ae9d5d6d29a6bc76a49099239d11ea524

+ 1 - 1
packages/client/ui-settings-plugins/README.md

@@ -57,7 +57,7 @@ The section is one extension point and one dispatch rule: feature plugins own th
 
 ### The tab extension point
 
-The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a tab stays mounted after its first selection so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it: chrome, controls, and copy. Tabs follow the contribution's `order`; cards follow registration order. The preset settings section registers into the roster's `settings.agentPreset.detail` slot and declares `settings.agentPreset.plugin.item`, keyed the same way; a card registers under both slots to appear on both surfaces. One scope selection serves every card — the global instance while the configuration tab shows, the preset's scope while its detail page is open; the settings shell mounts one section at a time, so the two never compete.
+The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs — a single contribution shows as the page itself, without a tab strip; a tab stays mounted after its first selection so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it: chrome, controls, and copy. Tabs follow the contribution's `order`; cards follow registration order. The preset settings section registers into the roster's `settings.agentPreset.detail` slot and declares `settings.agentPreset.plugin.item`, keyed the same way; a card registers under both slots to appear on both surfaces. One scope selection serves every card — the global instance while the configuration tab shows, the preset's scope while its detail page is open; the settings shell mounts one section at a time, so the two never compete.
 
 ### The write path
 

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

@@ -57,7 +57,7 @@ Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
 
 ### 标签页扩展点
 
-本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。标签页遵循贡献的 `order`;卡片遵循注册顺序。预设设置段注册进名单的 `settings.agentPreset.detail` 槽位并声明按同样方式键控的 `settings.agentPreset.plugin.item`;卡片在两个槽位下都注册才会出现在两个表面上。一个作用域选择服务所有卡片——配置标签页显示时是全局实例,预设详情页打开时是该预设的作用域;设置外壳一次只挂载一个分区,因此两者从不竞争。
+本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页——只有一个贡献时它直接就是这一页,没有标签条;某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。标签页遵循贡献的 `order`;卡片遵循注册顺序。预设设置段注册进名单的 `settings.agentPreset.detail` 槽位并声明按同样方式键控的 `settings.agentPreset.plugin.item`;卡片在两个槽位下都注册才会出现在两个表面上。一个作用域选择服务所有卡片——配置标签页显示时是全局实例,预设详情页打开时是该预设的作用域;设置外壳一次只挂载一个分区,因此两者从不竞争。
 
 ### 写入路径
 

+ 5 - 2
packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx

@@ -29,7 +29,7 @@ export type PluginsSettingsSectionProps =
   & PropsRenderSlots<'settings.plugins.tab'>
   & InjectFace<PluginsSettingsSectionInjected>
 
-/** Render one Plugins page whose contents arrive from feature-owned tabs. */
+/** Render one Plugins page whose contents arrive from feature-owned tabs; one contribution shows as the page itself. */
 export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettingsSectionProps) {
   const tabsId = useId()
   const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
@@ -37,6 +37,7 @@ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettin
   const [activeId, setActiveId] = useState<string>()
   const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
   const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
+  const single = rows.length === 1 ? rows[0] : undefined
 
   // A tab mounts only when first selected, then stays mounted while hidden so
   // local drafts, disclosure state, search, and the inventory snapshot survive
@@ -53,7 +54,9 @@ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettin
     <div className={css.section}>
       <h2 className={css.heading}>{t('title')}</h2>
       <p className={css.intro}>{t('intro')}</p>
-      {rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
+      {rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : single !== undefined ? (
+        <div className={css.panel}>{renderSlot('settings.plugins.tab', {}, { only: single.id })}</div>
+      ) : (
         <>
           <div className={css.tabs} role="tablist" aria-label={t('tabs')}>
             {rows.map((row, index) => {

+ 8 - 0
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -187,6 +187,14 @@ describe('PluginsSettingsSection', () => {
     expect(screen.getByText(en.intro)).toBeTruthy()
   })
 
+  it('shows a single contribution as the page itself, without a tab strip', () => {
+    renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
+
+    expect(screen.queryByRole('tablist')).toBeNull()
+    expect(screen.queryByRole('tab')).toBeNull()
+    expect(screen.getByText('configurable')).toBeTruthy()
+  })
+
   it('moves focus and selection with standard horizontal tab keys', () => {
     renderSection([
       { id: 'configurable', order: 0, label: en.configurableTab },

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

@@ -1385,6 +1385,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
     occupants: [
       'client-ui-conversation ConversationPanel key \'conversation\'',
+      'client-ui-settings-plugin-manager PluginManagerPage',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'main\', () => ctx.slots.register(\n      { name: \'main\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
@@ -1982,7 +1983,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'settings.section\' (client-ui-settings-plugins), so it exists while that entry is mounted',
     occupants: [
-      'client-ui-settings-plugin-manager PluginManagerSettingsTab id \'manage\'',
       'client-ui-settings-plugins ConfigurablePluginsTab id \'configurable\'',
     ],
     replaceRisk: 'none',
@@ -2297,7 +2297,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
-    occupants: [],
+    occupants: [
+      'client-ui-settings-plugin-manager PluginsPanelIcon',
+    ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n      { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:32',

+ 5 - 5
pnpm-lock.yaml

@@ -3684,18 +3684,18 @@ importers:
       '@deepseek-ai/dsh-client-ui-agent-preset':
         specifier: workspace:^
         version: link:../ui-agent-preset
+      '@deepseek-ai/dsh-client-ui-layout':
+        specifier: workspace:^
+        version: link:../ui-layout
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives
       '@deepseek-ai/dsh-client-ui-renderer':
         specifier: workspace:^
         version: link:../ui-renderer
-      '@deepseek-ai/dsh-client-ui-settings':
-        specifier: workspace:^
-        version: link:../ui-settings
-      '@deepseek-ai/dsh-client-ui-settings-plugins':
+      '@deepseek-ai/dsh-client-ui-sidebar':
         specifier: workspace:^
-        version: link:../ui-settings-plugins
+        version: link:../ui-sidebar
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots

+ 2 - 0
snapshots/web/lifecycle-chrome/hero.expected.md

@@ -4,6 +4,8 @@
 - button "New session":
   - img
   - text: New Session
+- navigation "Global panels":
+  - button "Plugins"
 - text: Workspaces
 - button "Search sessions":
   - img

+ 2 - 0
snapshots/web/lifecycle-chrome/plan-active.expected.md

@@ -4,6 +4,8 @@
 - button "New session":
   - img
   - text: New Session
+- navigation "Global panels":
+  - button "Plugins"
 - text: Workspaces
 - button "Search sessions":
   - img

部分文件因为文件数量过多而无法显示