Bläddra i källkod

feat(web): give every plugin package its own page

A card keeps the summary — name, one-liner, tags, its switch or Add to… —
and opens the package's page behind a crumb, as a preset card opens its
detail page: the version and source; a pack's rows as a list, every row on
one line with its state, its failure when one is recorded, and its switch
where the pack is switchable, with a filter past ten rows; the built-in
rows the pack changes; the modules the package declares addable, each
saying where it is composed, with an Add to… of its own; and retry and
uninstall. The expanded card and its component chips are gone with it.
Yichen Jiang 3 veckor sedan
förälder
incheckning
483e6b4920

+ 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: c733041e913c3f796f0f78bd9ac49bdf6e7776a2
-2026-09-04-plugin-management-in-web-settings.zh.md: 8fd91745ee5e9c39a498624a9b459d7901eb1551
+2026-09-04-plugin-management-in-web-settings.md: 3ee3cf099a169d106115d89853bec35f41513139
+2026-09-04-plugin-management-in-web-settings.zh.md: 1e48b71d4c504ecd33fee85fd38bc5635b7b0f67

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md


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

@@ -31,10 +31,10 @@
       - listitem:
         - text: "示例组合包 Web e2e fixture: a bundle whose one row is an inert plugin."
         - switch "启用 示例组合包"
-        - button "展开 示例组合包":
+        - button "查看 示例组合包":
           - img
       - listitem:
         - text: "示例插件 Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."
         - button "加入到…"
-        - button "展开 示例插件":
+        - button "查看 示例插件":
           - img

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

@@ -101,15 +101,17 @@ 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; uninstall waits in the expanded card.
+    // 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: '启用 示例组合包' })
     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: '查看 示例插件' }).click()
     await dialog.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
-    await dialog.getByRole('button', { name: '收起 示例插件' }).click()
+    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)
@@ -136,13 +138,12 @@ describe('web e2e: plugin manager', () => {
     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)
-    // The pack's components list from its probe. A profile that applies
-    // patches at its next start keeps them read-only: no component switch.
-    await dialog.getByRole('button', { name: '展开 示例组合包' }).click()
-    await dialog.getByRole('button', { name: '显示全部' }).click()
+    // 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 dialog.getByRole('button', { name: '返回插件列表' }).click()
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 

+ 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: 85b8fcbd942812ab4e1e10000c61bbaf55a02076
-README.zh.md: c678396f1a03c0b7a545bedc454b89ed7aa67ebb
+README.md: 41e35301487eb6ad58dc1bbe4d7ef4c2bb1e0355
+README.zh.md: 724bc3a9dfd9e355f223370bddccebaec0f88d5d

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
packages/client/ui-settings-plugin-manager/README.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
packages/client/ui-settings-plugin-manager/README.zh.md


+ 176 - 111
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css

@@ -114,18 +114,7 @@
   outline-offset: 1px;
 }
 
-.chevron {
-  flex: none;
-  color: var(--dsw-alias-label-tertiary);
-  transition: transform 120ms ease;
-}
-
-.chevronOpen {
-  transform: rotate(180deg);
-}
-
 .cards,
-.partExceptions,
 .chips {
   margin: 0;
   padding: 0;
@@ -274,15 +263,6 @@
   white-space: nowrap;
 }
 
-.cardBody {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  margin: 0 14px;
-  padding: 10px 0 12px;
-  border-top: 0.5px solid var(--dsw-alias-border-l2);
-}
-
 .facts {
   display: grid;
   grid-template-columns: max-content minmax(0, 1fr);
@@ -304,13 +284,6 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.subLabel {
-  margin: 2px 0 0;
-  font-size: 12px;
-  line-height: 18px;
-  color: var(--dsw-alias-label-tertiary);
-}
-
 .reason {
   margin: 0;
   font-size: 12px;
@@ -319,85 +292,10 @@
   overflow-wrap: anywhere;
 }
 
-.parts {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.partsHead {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-
 .partsHead .subLabel {
   margin: 0;
 }
 
-.partsCount {
-  font-size: 12px;
-  line-height: 18px;
-  color: var(--dsw-alias-label-secondary);
-}
-
-.partsToggle {
-  margin-left: auto;
-}
-
-.partExceptions {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.partException {
-  display: grid;
-  grid-template-columns: 7px minmax(0, 1fr) auto;
-  align-items: center;
-  column-gap: 8px;
-  row-gap: 2px;
-  padding: 6px 10px;
-  border-radius: 8px;
-  background: var(--dsw-alias-bg-layer-1);
-  font-size: 12.5px;
-  line-height: 18px;
-}
-
-.partId {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
-  font-size: 12px;
-}
-
-.partWhy {
-  font-size: 12px;
-  line-height: 18px;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.partGroup {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.partFailure {
-  grid-column: 2 / -1;
-  margin: 0;
-  font-size: 12px;
-  color: var(--dsw-alias-state-error-primary);
-  overflow-wrap: anywhere;
-}
-
-.partsAll {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
 .partsFilter {
   width: 200px;
 }
@@ -427,15 +325,6 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-.phase {
-  display: inline-flex;
-  flex: none;
-}
-
-.chipSwitch {
-  margin: 0 -4px 0 2px;
-}
-
 .installBody {
   display: flex;
   flex-direction: column;
@@ -561,3 +450,179 @@
   --dsw-alias-button-primary-fill: var(--dsw-alias-state-error-primary);
   --dsw-alias-button-primary-hover: var(--dsw-alias-state-error-primary);
 }
+
+/* The way into a package's page: the list chevron turned to point right. */
+.chevronRight {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+  transform: rotate(-90deg);
+}
+
+.detail {
+  display: flex;
+  flex-direction: column;
+}
+
+.crumb {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  border: 0;
+  padding: 0;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+  font: inherit;
+  font-size: 12.5px;
+  cursor: pointer;
+}
+
+.crumb:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumb:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.crumbIcon {
+  transform: rotate(90deg);
+}
+
+.crumbSep {
+  width: 1px;
+  height: 12px;
+  background: var(--dsw-alias-border-l3);
+}
+
+.crumbHere {
+  color: var(--dsw-alias-label-primary);
+}
+
+.detailHead {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  margin: 12px 0 8px;
+}
+
+.detailMain {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+}
+
+.detailTitle {
+  margin: 0;
+  font-size: 20px;
+  line-height: 28px;
+  font-weight: 600;
+}
+
+.detailDesc {
+  margin: 0;
+  max-width: 64ch;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 14px;
+  line-height: 22px;
+}
+
+.detailSections {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+  margin-top: 16px;
+}
+
+.detailSections > * + * {
+  padding-top: 18px;
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+.detailSection {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.sectionHead {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+}
+
+.sectionTitle {
+  margin: 0;
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.sectionCount {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.rows {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.row {
+  display: grid;
+  grid-template-columns: 7px minmax(0, 1fr) auto;
+  align-items: center;
+  column-gap: 10px;
+  padding: 8px 12px;
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.row[data-state='off'] {
+  border: 0.5px dashed var(--dsw-alias-border-l4);
+  background: transparent;
+}
+
+.rowMain {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 2px 10px;
+  min-width: 0;
+}
+
+.rowId {
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+  font-size: 12.5px;
+  color: var(--dsw-alias-label-primary);
+  overflow-wrap: anywhere;
+}
+
+.row[data-state='off'] .rowId {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.rowState {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.row[data-state='failed'] .rowState {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.rowFailure {
+  flex-basis: 100%;
+  margin: 0;
+  font-size: 12px;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+}

+ 326 - 199
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -5,14 +5,14 @@
  * a restart is pending or something is wrong; 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 — each with its own switch on an
- * external pack composed on a live-reload profile — and its uninstall. A preset's
+ * kinds, and probe facts stay off the page. A card opens the package's own
+ * page: its version and source, its rows — each with its own switch on an
+ * external pack composed on a live-reload profile — the built-in rows it
+ * changes, the modules it declares addable, and its uninstall. A preset's
  * composition lives on the preset's own detail page (`PresetPluginsSection`).
  */
 
 import { useEffect, useId, useState, type ReactNode } from 'react'
-import clsx from 'clsx'
 import type { PluginInstallRejection, PluginPackageView, PluginRowTarget } from '@deepseek-ai/dsh-api-remotes/client'
 import {
   Button, IconChevronDownOutline14, IconRefreshOutline16, Input, Menu, Modal, StateDot, Switch, Tag,
@@ -72,16 +72,6 @@ const PHASE_STATES = {
   unloading: 'idle',
 } satisfies Record<RowPhase, StateDotState>
 
-/** One fiber phase as a state dot, named for assistive technology by the phase's copy. */
-function PhaseDot({ phase, t }: { readonly phase: RowPhase; readonly t: Translate }): ReactNode {
-  const label = t(PHASE_KEYS[phase])
-  return (
-    <span className={css.phase} role="img" aria-label={label} title={label}>
-      <StateDot state={PHASE_STATES[phase]} size={8} />
-    </span>
-  )
-}
-
 /** A component with a recorded startup failure or a failed fiber. */
 function isFailedRow(row: RowView): boolean {
   return row.failure !== undefined || row.phase === 'failed'
@@ -100,24 +90,26 @@ function partsSummary(rows: readonly RowView[], t: Translate): string {
   ].join(' · ')
 }
 
-/** The switch one component renders: whether a write is in flight for it, and the write it asks for. */
-interface PartToggle {
+/** The switch one row renders: whether a write is in flight for it, and the write it asks for. */
+interface RowToggle {
   readonly busy: boolean
   readonly onChange: (disabled: boolean) => void
 }
 
-/** Switching for a pack's components: which rows have a write in flight, and the write. */
-interface PartToggles {
+/** Switching for a pack's rows: which rows have a write in flight, and the write. */
+interface RowToggles {
   readonly busy: (rowId: string) => boolean
   readonly onSetDisabled: (rowId: string, disabled: boolean) => void
 }
 
-/** A component's switch: off when the user switched it off; locked, saying why, when the pack itself keeps it off. */
-function PartSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Translate; readonly toggle: PartToggle }): ReactNode {
+/** Rows beyond this count get a filter box above the list. */
+const ROW_FILTER_THRESHOLD = 10
+
+/** A row's switch: off when the person switched it off; locked, saying why, when the pack itself keeps it off. */
+function RowSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Translate; readonly toggle: RowToggle }): ReactNode {
   const locked = row.disabledBy === 'composition'
   return (
     <Switch
-      className={css.chipSwitch}
       checked={row.enabled}
       label={t('partToggle', { name: row.rowId })}
       disabled={toggle.busy || locked}
@@ -127,117 +119,176 @@ function PartSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Tra
   )
 }
 
-/** One component as a chip: its declared id, its fiber phase when it has one, and its switch when it has one. */
-function PartChip({ row, off, t, toggle }: {
-  readonly row: RowView
-  readonly off: boolean
-  readonly t: Translate
-  readonly toggle?: PartToggle | undefined
-}): ReactNode {
-  return (
-    <li className={css.chip} data-plugin-row={row.entryId} {...off ? { 'data-state': 'off' } : {}}>
-      {row.phase === null ? null : <PhaseDot phase={row.phase} t={t} />}
-      {row.rowId}
-      {toggle === undefined ? null : <PartSwitch row={row} t={t} toggle={toggle} />}
-    </li>
-  )
+/** What a row's state line says: the failure, why it is off, or the phase its fiber is in. */
+function rowStateText(row: RowView, t: Translate): string {
+  if (isFailedRow(row)) return t('rowStateFailed')
+  if (!row.enabled) return t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')
+  return row.phase === null ? t('rowStateIdle') : t(PHASE_KEYS[row.phase])
+}
+
+/** The dot beside a row: its failure, its fiber phase, or idle. */
+function rowDotState(row: RowView): StateDotState {
+  if (isFailedRow(row)) return 'error'
+  if (!row.enabled || row.phase === null) return 'idle'
+  return PHASE_STATES[row.phase]
 }
 
 /**
- * The components of one plugin pack: a count line; the failing components,
- * one line each with the failure; the off ones as chips under why they are
- * off; and the rest as chips behind **Show all** with a filter. A pack like
- * base carries close to a hundred rows and switches dozens off by design,
- * so only a failure earns a line of its own. With `toggle`, every component
- * carries a switch, except a row another layer owns: nothing of it mounted.
+ * A pack's rows as a list in the order the pack declares them: a state dot,
+ * the row id, one line saying its state, the failure when one is recorded,
+ * and, when the pack is switchable, a switch — except on a row another layer
+ * owns, of which nothing mounted. A pack like base carries close to a hundred
+ * rows, so a long list gets a filter.
  */
-function BundleParts({ rows, t, toggle }: {
+function RowsSection({ rows, t, toggle }: {
   readonly rows: readonly RowView[]
   readonly t: Translate
-  readonly toggle?: PartToggles | undefined
+  readonly toggle?: RowToggles | undefined
 }): ReactNode {
-  const partToggle = (row: RowView): PartToggle | undefined => toggle === undefined || row.failure?.stage === 'conflict'
-    ? undefined
-    : { busy: toggle.busy(row.rowId), onChange: (disabled) => { toggle.onSetDisabled(row.rowId, disabled) } }
-  const [open, setOpen] = useState(false)
   const [filter, setFilter] = useState('')
-  const failed = rows.filter(isFailedRow)
-  const off = rows.filter(row => !isFailedRow(row) && !row.enabled)
-  const offGroups = [
-    { key: 'partDisabledByUser' as const, rows: off.filter(row => row.disabledBy === 'user') },
-    { key: 'partDisabledByComposition' as const, rows: off.filter(row => row.disabledBy !== 'user') },
-  ].filter(group => group.rows.length > 0)
-  const rest = rows.filter(row => !isFailedRow(row) && row.enabled)
   const query = filter.trim().toLowerCase()
-  const shown = query === '' ? rest : rest.filter(row => row.rowId.toLowerCase().includes(query))
+  const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query))
   return (
-    <div className={css.parts}>
-      <div className={css.partsHead}>
-        <span className={css.subLabel}>{t('partsLabel')}</span>
-        {rows.length === 0 ? null : <span className={css.partsCount}>{partsSummary(rows, t)}</span>}
-        {rest.length === 0
-          ? null
-          : (
-            <Button
-              variant="ghost"
-              size="sm"
-              className={css.partsToggle}
-              aria-expanded={open}
-              onClick={() => { setOpen(current => !current) }}
-            >
-              {t(open ? 'partsCollapse' : 'partsShowAll')}
-            </Button>
-          )}
+    <section className={css.detailSection} data-plugin-rows>
+      <div className={css.sectionHead}>
+        <h4 className={css.sectionTitle}>{t('partsLabel')}</h4>
+        {rows.length === 0 ? null : <span className={css.sectionCount}>{partsSummary(rows, t)}</span>}
       </div>
       {rows.length === 0 ? <p className={css.status}>{t('partsEmpty')}</p> : null}
-      {failed.length === 0
+      {rows.length > ROW_FILTER_THRESHOLD
+        ? (
+          <Input
+            type="search"
+            className={css.partsFilter as string}
+            placeholder={t('partsFilter')}
+            aria-label={t('partsFilter')}
+            value={filter}
+            onChange={(event) => { setFilter(event.target.value) }}
+          />
+        )
+        : null}
+      {rows.length > 0 && shown.length === 0 ? <p className={css.status}>{t('partsFilterEmpty')}</p> : null}
+      {shown.length === 0
         ? null
         : (
-          <ul className={css.partExceptions}>
-            {failed.map((row) => {
-              const rowToggle = partToggle(row)
+          <ul className={css.rows}>
+            {shown.map((row) => {
+              const rowToggle = toggle === undefined || row.failure?.stage === 'conflict'
+                ? undefined
+                : { busy: toggle.busy(row.rowId), onChange: (disabled: boolean) => { toggle.onSetDisabled(row.rowId, disabled) } }
               return (
-                <li key={row.entryId} className={css.partException} data-plugin-row={row.entryId}>
-                  <StateDot state="error" size={8} />
-                  <span className={css.partId}>{row.rowId}</span>
-                  <Tag tone="danger">{t('rowStateFailed')}</Tag>
-                  {rowToggle === undefined ? null : <PartSwitch row={row} t={t} toggle={rowToggle} />}
-                  {row.failure === undefined ? null : <p className={css.partFailure}>{row.failure.message}</p>}
+                <li
+                  key={row.entryId}
+                  className={css.row}
+                  data-plugin-row={row.entryId}
+                  {...isFailedRow(row) ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
+                >
+                  <StateDot state={rowDotState(row)} size={8} />
+                  <div className={css.rowMain}>
+                    <span className={css.rowId}>{row.rowId}</span>
+                    <span className={css.rowState}>{rowStateText(row, t)}</span>
+                    {row.failure === undefined ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
+                  </div>
+                  {rowToggle === undefined ? null : <RowSwitch row={row} t={t} toggle={rowToggle} />}
                 </li>
               )
             })}
           </ul>
         )}
-      {offGroups.map(group => (
-        <div key={group.key} className={css.partGroup}>
-          <span className={css.partWhy}>{t('partsGroupLabel', { reason: t(group.key), count: String(group.rows.length) })}</span>
-          <ul className={css.chips}>
-            {group.rows.map(row => <PartChip key={row.entryId} row={row} off t={t} toggle={partToggle(row)} />)}
-          </ul>
-        </div>
-      ))}
-      {open
-        ? (
-          <div className={css.partsAll}>
-            <Input
-              type="search"
-              className={css.partsFilter as string}
-              placeholder={t('partsFilter')}
-              aria-label={t('partsFilter')}
-              value={filter}
-              onChange={(event) => { setFilter(event.target.value) }}
-            />
-            {shown.length === 0
-              ? <p className={css.status}>{t('partsFilterEmpty')}</p>
-              : (
-                <ul className={css.chips}>
-                  {shown.map(row => <PartChip key={row.entryId} row={row} off={false} t={t} toggle={partToggle(row)} />)}
-                </ul>
-              )}
-          </div>
-        )
-        : null}
-    </div>
+    </section>
+  )
+}
+
+/** The built-in rows the pack's patch changes, by id: the one thing a pack does outside its own rows. */
+function OverridesSection({ overrides, t }: { readonly overrides: readonly string[]; readonly t: Translate }): ReactNode {
+  return (
+    <section className={css.detailSection} data-plugin-overrides>
+      <div className={css.sectionHead}>
+        <h4 className={css.sectionTitle}>{t('overridesLabel')}</h4>
+      </div>
+      <ul className={css.chips}>
+        {overrides.map(id => <li key={id} className={css.chip}>{id}</li>)}
+      </ul>
+    </section>
+  )
+}
+
+/** Where one module is composed already: every session, then the presets that carry it. */
+function joinedTargets(
+  entry: PluginPackageView['addable'][number], presets: readonly PresetGroup[], globalModules: readonly string[],
+  t: Translate, presetName: (preset: PresetGroup) => string,
+): string[] {
+  return [
+    ...globalModules.includes(entry.moduleName) ? [t('joinedGlobal')] : [],
+    ...presets.filter(preset => preset.rows.some(row => row.moduleName === entry.moduleName)).map(presetName),
+  ]
+}
+
+/** The modules a package declares addable: each with where it is composed, or why it cannot be, and its **Add to…** menu. */
+function ModulesSection({ pkg, t, busy, presets, globalModules, presetName, onAddRow }: {
+  readonly pkg: PluginPackageView
+  readonly t: Translate
+  readonly busy: boolean
+  readonly presets: readonly PresetGroup[]
+  readonly globalModules: readonly string[]
+  readonly presetName: (preset: PresetGroup) => string
+  readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void
+}): ReactNode {
+  const [openMenu, setOpenMenu] = useState<string | null>(null)
+  const title = pkg.title ?? shortName(pkg.name)
+  return (
+    <section className={css.detailSection} data-plugin-modules>
+      <div className={css.sectionHead}>
+        <h4 className={css.sectionTitle}>{t('modulesLabel')}</h4>
+      </div>
+      <ul className={css.rows}>
+        {pkg.addable.map((entry) => {
+          const joined = joinedTargets(entry, presets, globalModules, t, presetName)
+          const open = openMenu === entry.moduleName
+          return (
+            <li key={entry.moduleName} className={css.row} data-plugin-module={entry.moduleName}>
+              <StateDot state={entry.ok ? joined.length > 0 ? 'done' : 'idle' : 'error'} size={8} />
+              <div className={css.rowMain}>
+                <span className={css.rowId}>{entry.title ?? (entry.declaredName === '.' ? title : entry.declaredName)}</span>
+                <span className={css.rowState}>
+                  {entry.ok
+                    ? joined.length > 0 ? t('moduleJoined', { targets: joined.join(t('joinedSeparator')) }) : t('moduleNotJoined')
+                    : t('moduleBroken', { error: entry.error ?? '' })}
+                </span>
+              </div>
+              {entry.ok
+                ? (
+                  <Menu
+                    open={open}
+                    onClose={() => { setOpenMenu(null) }}
+                    items={addTargets(entry, presets, globalModules, t, presetName)}
+                    onSelect={(id) => {
+                      setOpenMenu(null)
+                      const choice = JSON.parse(id) as AddChoice
+                      onAddRow(choice.declaredName, choice.target)
+                    }}
+                    align="end"
+                    portal
+                    anchor={(
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        aria-haspopup="menu"
+                        aria-expanded={open}
+                        disabled={busy}
+                        onClick={() => { setOpenMenu(open ? null : entry.moduleName) }}
+                      >
+                        {t('addTo')}
+                      </Button>
+                    )}
+                  />
+                )
+                : null}
+            </li>
+          )
+        })}
+      </ul>
+    </section>
   )
 }
 
@@ -267,33 +318,23 @@ function addTargets(
   ]
 }
 
-/** 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, rowBusy, onSetRowDisabled,
-}: {
+/** One installed package as a card: its name, its one-liner, its tags, its switch or its **Add to…** menu, and the way into its page. */
+function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen, onSetEnabled, onAddRow }: {
   readonly pkg: PluginPackageView
   readonly t: Translate
   readonly busy: boolean
-  readonly open: boolean
   readonly presets: readonly PresetGroup[]
   readonly globalModules: readonly string[]
   readonly presetName: (preset: PresetGroup) => string
-  readonly onToggleOpen: () => void
+  readonly onOpen: () => void
   readonly onSetEnabled: (enabled: boolean) => void
-  readonly onRetry: () => void
-  readonly onUninstall: () => void
   readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void
-  /** Whether a component's row has a write in flight. */
-  readonly rowBusy: (rowId: string) => boolean
-  readonly onSetRowDisabled: (rowId: string, disabled: boolean) => void
 }): ReactNode {
   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 detailId = `plugin-package-${encodeURIComponent(pkg.name)}`
   const addable = pkg.addable.filter(entry => entry.ok)
   const [single] = addable
   const menuItems: MenuItem[] = single !== undefined && addable.length === 1
@@ -303,18 +344,8 @@ function PackageCard({
       label: entry.title ?? entry.declaredName,
       submenu: addTargets(entry, presets, globalModules, t, presetName),
     }))
-  const retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
-  const removable = pkg.installed && !builtin
-  // A component's switch acts at once only on an external pack composed on a
-  // profile that applies patches while it runs; elsewhere the chips stay read-only.
-  const switchable = bundle && !builtin && pkg.enabled && pkg.liveReload
   return (
-    <li
-      className={css.card}
-      data-plugin-package={pkg.name}
-      data-plugin-status={pkg.status}
-      data-open={open ? 'true' : undefined}
-    >
+    <li className={css.card} data-plugin-package={pkg.name} data-plugin-status={pkg.status}>
       <div className={css.cardHead}>
         <div className={css.cardMain}>
           <div className={css.titleRow}>
@@ -368,63 +399,139 @@ function PackageCard({
           <button
             type="button"
             className={css.iconButton}
-            aria-expanded={open}
-            aria-controls={detailId}
-            aria-label={t(open ? 'collapse' : 'expand', { name: title })}
-            onClick={onToggleOpen}
+            aria-label={t('openDetail', { name: title })}
+            title={t('openDetailTip')}
+            onClick={onOpen}
           >
-            <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden="true" />
+            <IconChevronDownOutline14 className={css.chevronRight} aria-hidden="true" />
           </button>
         </div>
       </div>
-      {open
-        ? (
-          <div className={css.cardBody} id={detailId}>
-            {pkg.reason === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
-            <dl className={css.facts}>
-              {pkg.version === undefined ? null : <><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></>}
-              <dt>{t('sourceLabel')}</dt>
-              <dd>{t(builtin ? 'sourceBuiltin' : 'sourceLocal')}</dd>
-            </dl>
-            {bundle
-              ? (
-                <BundleParts
-                  rows={pkg.rows}
-                  t={t}
-                  toggle={switchable ? { busy: rowId => busy || rowBusy(rowId), onSetDisabled: onSetRowDisabled } : undefined}
-                />
-              )
-              : null}
-            {retryable || removable
-              ? (
-                <div className={css.actions}>
-                  {retryable
-                    ? <Button variant="ghost" size="sm" disabled={busy} onClick={onRetry}>{t('retryPackage')}</Button>
-                    : null}
-                  {removable
-                    ? (
-                      <Button
-                        variant="ghost"
-                        size="sm"
-                        className={css.danger}
-                        aria-label={t('uninstallLabel', { name: title })}
-                        disabled={busy}
-                        onClick={onUninstall}
-                      >
-                        {t('uninstall')}
-                      </Button>
-                    )
-                    : null}
-                </div>
-              )
-              : null}
-          </div>
-        )
-        : null}
     </li>
   )
 }
 
+/**
+ * One package's page: the crumb back to the list; its name, tags, one-liner,
+ * and its switch; the reason when one applies; its version and source; its
+ * rows with their switches; the built-in rows it changes; the modules it
+ * declares addable with where each is composed; and retry and uninstall.
+ */
+function PackageDetail({
+  pkg, t, busy, rowBusy, presets, globalModules, presetName,
+  onBack, onSetEnabled, onRetry, onUninstall, onAddRow, onSetRowDisabled,
+}: {
+  readonly pkg: PluginPackageView
+  readonly t: Translate
+  readonly busy: boolean
+  /** Whether a row has a write in flight. */
+  readonly rowBusy: (rowId: string) => boolean
+  readonly presets: readonly PresetGroup[]
+  readonly globalModules: readonly string[]
+  readonly presetName: (preset: PresetGroup) => string
+  readonly onBack: () => void
+  readonly onSetEnabled: (enabled: boolean) => void
+  readonly onRetry: () => void
+  readonly onUninstall: () => void
+  readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void
+  readonly onSetRowDisabled: (rowId: string, disabled: boolean) => void
+}): ReactNode {
+  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 retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
+  const removable = pkg.installed && !builtin
+  // A row's switch acts at once only on an external pack composed on a
+  // profile that applies patches while it runs; elsewhere the rows stay read-only.
+  const switchable = bundle && !builtin && pkg.enabled && pkg.liveReload
+  return (
+    <div className={css.detail} data-plugin-detail={pkg.name}>
+      <button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
+        <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
+        <span>{t('crumbRoot')}</span>
+        <span className={css.crumbSep} aria-hidden="true" />
+        <span className={css.crumbHere}>{title}</span>
+      </button>
+      <div className={css.detailHead}>
+        <div className={css.detailMain}>
+          <div className={css.titleRow}>
+            <h3 className={css.detailTitle}>{title}</h3>
+            {builtin ? <Tag>{t('builtinTag')}</Tag> : null}
+            {status === null ? null : <Tag tone={status === 'restart' ? 'warning' : 'danger'}>{t(STATUS_KEYS[status])}</Tag>}
+          </div>
+          <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
+        </div>
+        {bundle
+          ? (
+            <Switch
+              checked={pkg.enabled}
+              label={t('enableToggle', { name: title })}
+              disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
+              {...builtin ? { title: t('builtinLocked') } : {}}
+              onChange={onSetEnabled}
+            />
+          )
+          : null}
+      </div>
+      {pkg.reason === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
+      <dl className={css.facts}>
+        {pkg.version === undefined ? null : <><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></>}
+        <dt>{t('sourceLabel')}</dt>
+        <dd>{t(builtin ? 'sourceBuiltin' : 'sourceExternal')}</dd>
+      </dl>
+      <div className={css.detailSections}>
+        {bundle
+          ? (
+            <RowsSection
+              rows={pkg.rows}
+              t={t}
+              toggle={switchable ? { busy: rowId => busy || rowBusy(rowId), onSetDisabled: onSetRowDisabled } : undefined}
+            />
+          )
+          : null}
+        {pkg.overrides.length === 0 ? null : <OverridesSection overrides={pkg.overrides} t={t} />}
+        {pkg.addable.length === 0
+          ? null
+          : (
+            <ModulesSection
+              pkg={pkg}
+              t={t}
+              busy={busy}
+              presets={presets}
+              globalModules={globalModules}
+              presetName={presetName}
+              onAddRow={onAddRow}
+            />
+          )}
+        {retryable || removable
+          ? (
+            <div className={css.actions}>
+              {retryable
+                ? <Button variant="ghost" size="sm" disabled={busy} onClick={onRetry}>{t('retryPackage')}</Button>
+                : null}
+              {removable
+                ? (
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className={css.danger}
+                    aria-label={t('uninstallLabel', { name: title })}
+                    disabled={busy}
+                    onClick={onUninstall}
+                  >
+                    {t('uninstall')}
+                  </Button>
+                )
+                : null}
+            </div>
+          )
+          : null}
+      </div>
+    </div>
+  )
+}
+
 /** The sentence for one package the Host removed again after the install. */
 function removedText(entry: PluginInstallRejection, t: Translate): string {
   return entry.reason.startsWith('row ')
@@ -567,20 +674,26 @@ function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, o
 export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
   const { t, presetName, ensure } = props
   const state = props.usePluginManager(snapshot => snapshot)
-  const [expanded, setExpanded] = useState<string | null>(null)
+  // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards.
+  const [openPackage, setOpenPackage] = useState<string | null>(null)
   useEffect(() => { ensure() }, [ensure])
 
   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'
+  const openPkg = openPackage === null ? undefined : state.packages.find(pkg => pkg.name === openPackage)
 
   return (
     <div className={css.section} aria-busy={state.status === 'loading'}>
-      <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>
+      {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>
+        )
+        : null}
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
       {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
       {state.status === 'error'
@@ -602,7 +715,26 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
             <Button variant="ghost" size="sm" onClick={props.dismissNotice}>{t('dismiss')}</Button>
           </p>
         )}
-      {loaded
+      {loaded && openPkg !== undefined
+        ? (
+          <PackageDetail
+            pkg={openPkg}
+            t={t}
+            busy={state.busy.includes(openPkg.name)}
+            rowBusy={rowId => state.busy.includes(rowKey(GLOBAL, rowId))}
+            presets={state.presets}
+            globalModules={state.globalModules}
+            presetName={presetName}
+            onBack={() => { setOpenPackage(null) }}
+            onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
+            onRetry={() => { props.retry(openPkg.name) }}
+            onUninstall={() => { props.uninstall(openPkg.name) }}
+            onAddRow={(declaredName, target) => { props.addRow(openPkg.name, declaredName, target) }}
+            onSetRowDisabled={(rowId, disabled) => { props.setRowDisabled(GLOBAL, rowId, disabled) }}
+          />
+        )
+        : null}
+      {loaded && openPkg === undefined
         ? (
           <section className={css.group} data-plugin-scope="global">
             <div className={css.groupTitleRow}>
@@ -619,17 +751,12 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
                       pkg={pkg}
                       t={t}
                       busy={state.busy.includes(pkg.name)}
-                      open={expanded === pkg.name}
                       presets={state.presets}
                       globalModules={state.globalModules}
                       presetName={presetName}
-                      onToggleOpen={() => { setExpanded(current => current === pkg.name ? null : pkg.name) }}
+                      onOpen={() => { setOpenPackage(pkg.name) }}
                       onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
-                      onRetry={() => { props.retry(pkg.name) }}
-                      onUninstall={() => { props.uninstall(pkg.name) }}
                       onAddRow={(declaredName, target) => { props.addRow(pkg.name, declaredName, target) }}
-                      rowBusy={rowId => state.busy.includes(rowKey(GLOBAL, rowId))}
-                      onSetRowDisabled={(rowId, disabled) => { props.setRowDisabled(GLOBAL, rowId, disabled) }}
                     />
                   ))}
                 </ul>

+ 28 - 12
packages/client/ui-settings-plugin-manager/src/client/locales.ts

@@ -36,18 +36,16 @@ export const zh = {
   versionLabel: '版本',
   sourceLabel: '来源',
   sourceBuiltin: '内置',
-  sourceLocal: '本地',
+  sourceExternal: '第三方',
+  noDescription: '暂无描述。',
   partsLabel: '包含的组件',
   partsEmpty: '这个插件包不包含任何组件。',
   partsCountTotal: '共 {count} 个',
   partsCountRunning: '{count} 运行中',
   partsCountOff: '{count} 未启用',
   partsCountFailed: '{count} 异常',
-  partsShowAll: '显示全部',
-  partsCollapse: '收起',
   partsFilter: '筛选组件',
   partsFilterEmpty: '没有匹配的组件。',
-  partsGroupLabel: '{reason}({count})',
   partDisabledByUser: '已停用 · 你关闭的',
   partDisabledByComposition: '未启用 · 插件包自身关闭',
   partToggle: '启用组件 {name}',
@@ -59,9 +57,19 @@ export const zh = {
   rowPhaseUnloading: '卸载中',
   enableToggle: '启用 {name}',
   builtinLocked: '内置功能随产品启动,不能在这里停用。',
-  expand: '展开 {name}',
-  collapse: '收起 {name}',
   addTo: '加入到…',
+  openDetail: '查看 {name}',
+  openDetailTip: '详情',
+  backToList: '返回插件列表',
+  crumbRoot: '插件管理',
+  rowStateIdle: '未运行',
+  overridesLabel: '改动了这些内置组件',
+  modulesLabel: '可加入的模块',
+  moduleJoined: '已加入:{targets}',
+  moduleNotJoined: '尚未加入',
+  moduleBroken: '无法导入:{error}',
+  joinedGlobal: '所有会话',
+  joinedSeparator: '、',
   addToGlobal: '所有会话',
   addToGlobalAdded: '所有会话(已加入)',
   addToPreset: '预设:{name}',
@@ -222,18 +230,16 @@ export const en = {
   versionLabel: 'Version',
   sourceLabel: 'Source',
   sourceBuiltin: 'Built-in',
-  sourceLocal: 'Local',
+  sourceExternal: 'Third-party',
+  noDescription: 'No description.',
   partsLabel: 'Components',
   partsEmpty: 'This plugin pack contains no components.',
   partsCountTotal: '{count} total',
   partsCountRunning: '{count} running',
   partsCountOff: '{count} off',
   partsCountFailed: '{count} failed',
-  partsShowAll: 'Show all',
-  partsCollapse: 'Collapse',
   partsFilter: 'Filter components',
   partsFilterEmpty: 'No component matches.',
-  partsGroupLabel: '{reason} ({count})',
   partDisabledByUser: 'Off · switched off by you',
   partDisabledByComposition: 'Not enabled · off in the pack itself',
   partToggle: 'Enable component {name}',
@@ -245,9 +251,19 @@ export const en = {
   rowPhaseUnloading: 'Unloading',
   enableToggle: 'Enable {name}',
   builtinLocked: 'A built-in feature starts with the product and cannot be switched off here.',
-  expand: 'Show {name}',
-  collapse: 'Hide {name}',
   addTo: 'Add to…',
+  openDetail: 'View {name}',
+  openDetailTip: 'Details',
+  backToList: 'Back to plugins',
+  crumbRoot: 'Manage plugins',
+  rowStateIdle: 'Not running',
+  overridesLabel: 'Changes these built-in components',
+  modulesLabel: 'Modules to add',
+  moduleJoined: 'Added to: {targets}',
+  moduleNotJoined: 'Not added yet',
+  moduleBroken: 'Cannot be imported: {error}',
+  joinedGlobal: 'Every session',
+  joinedSeparator: ', ',
   addToGlobal: 'All sessions',
   addToGlobalAdded: 'All sessions (added)',
   addToPreset: 'Preset: {name}',

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

@@ -161,24 +161,27 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.queryByRole('switch', { name: 'Enable unknown' })).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' }))
+    // Uninstall lives on the package's page, for installed packages the person added.
+    fireEvent.click(screen.getByRole('button', { name: 'View 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' }))
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: 'View 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)
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: 'View First party' }))
+    expect(document.querySelector('[data-plugin-detail="@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', () => {
+  it('opens a plugin pack\'s page with its facts, rows, and overrides, and retries a failing one', () => {
     const { actions, set } = renderTab({
       packages: [
         pkg({
           status: 'partial',
           reason: 'one row failed',
+          overrides: ['directory-picker'],
           rows: [
             { entryId: 'include:better-sidebar', rowId: 'better-sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active' },
             { entryId: 'include:off', rowId: 'off', moduleName: 'dsh-better-sidebar/off', enabled: false, disabledBy: 'user', phase: null },
@@ -189,10 +192,6 @@ describe('PluginManagerSettingsTab', () => {
           ],
         }),
         pkg({ name: 'no-rows', enabled: false, status: 'disabled' }),
-        pkg({
-          name: 'all-off', enabled: false, status: 'disabled',
-          rows: [{ entryId: 'include:o1', rowId: 'o1', moduleName: 'all-off', enabled: false, disabledBy: 'composition', phase: null }],
-        }),
         pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }),
         pkg({
           name: '@deepseek-ai/dsh-core-broken', title: 'Core', trust: 'builtin', status: 'failed',
@@ -200,70 +199,67 @@ describe('PluginManagerSettingsTab', () => {
         }),
       ],
     })
-    fireEvent.click(screen.getByRole('button', { name: 'Show better-sidebar' }))
+    // The list and its toolbar give way to the page; the crumb leads back.
+    fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
+    expect(screen.queryByRole('button', { name: en.addPlugin })).toBeNull()
+    expect(screen.getByRole('heading', { name: 'better-sidebar' })).toBeTruthy()
     expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
     expect(screen.getByText('0.16.0')).toBeTruthy()
-    expect(screen.getByText(en.sourceLocal)).toBeTruthy()
-    // The count line; the failing components one line each; the off ones as
-    // chips under why they are off; the running ones wait behind Show all.
+    expect(screen.getByText(en.sourceExternal)).toBeTruthy()
+    // Every row on one line, in the pack's order, saying its state; a failure adds its message.
     expect(screen.getByText('6 total · 1 running · 2 off · 2 failed')).toBeTruthy()
-    expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')).toBeNull()
-    expect(screen.getByText(`${en.partDisabledByUser} (1)`)).toBeTruthy()
-    expect(screen.getByText(`${en.partDisabledByComposition} (1)`)).toBeTruthy()
+    const rowText = (id: string): string | undefined => document.querySelector(`[data-plugin-row="include:${id}"]`)?.textContent
+    expect(rowText('better-sidebar')).toBe(`better-sidebar${en.rowPhaseActive}`)
+    expect(rowText('off')).toBe(`off${en.partDisabledByUser}`)
+    expect(rowText('gated')).toBe(`gated${en.partDisabledByComposition}`)
+    expect(rowText('crash')).toBe(`crash${en.rowStateFailed}boom`)
+    expect(rowText('flaky')).toBe(`flaky${en.rowStateFailed}`)
+    expect(rowText('idle')).toBe(`idle${en.rowStateIdle}`)
     expect(document.querySelector('[data-plugin-row="include:off"]')?.getAttribute('data-state')).toBe('off')
-    expect(document.querySelector('[data-plugin-row="include:gated"]')?.textContent).toBe('gated')
-    expect(document.querySelector('[data-plugin-row="include:crash"]')?.textContent).toContain(en.rowStateFailed)
-    expect(document.querySelector('[data-plugin-row="include:flaky"]')?.textContent).toBe(`flaky${en.rowStateFailed}`)
-    expect(screen.getByText('boom')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.partsShowAll }))
-    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')
-    expect(document.querySelector('[data-plugin-row="include:idle"]')?.textContent).toBe('idle')
-    expect(document.querySelector('[data-plugin-row="include:idle"]')?.getAttribute('data-state')).toBeNull()
-    const filter = screen.getByRole('searchbox', { name: en.partsFilter })
-    fireEvent.change(filter, { target: { value: 'nope' } })
-    expect(screen.getByText(en.partsFilterEmpty)).toBeTruthy()
-    fireEvent.change(filter, { target: { value: 'SIDE' } })
-    expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')).toBeTruthy()
-    expect(document.querySelector('[data-plugin-row="include:idle"]')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: en.partsCollapse }))
-    expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')).toBeNull()
+    expect(document.querySelector('[data-plugin-row="include:crash"]')?.getAttribute('data-state')).toBe('failed')
+    // A short list has no filter; the built-in rows the pack changes are named.
+    expect(screen.queryByRole('searchbox', { name: en.partsFilter })).toBeNull()
+    expect(document.querySelector('[data-plugin-overrides]')?.textContent).toContain('directory-picker')
+    expect(screen.queryByText(en.modulesLabel)).toBeNull()
 
     fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
     expect(actions.retry).toHaveBeenCalledWith('dsh-better-sidebar')
-
+    fireEvent.click(screen.getByRole('switch', { name: 'Enable better-sidebar' }))
+    expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
     // 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' }))
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
     expect(screen.queryByText(en.partsLabel)).toBeNull()
+    expect(screen.getByRole('button', { name: en.addPlugin })).toBeTruthy()
 
-    // 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' }))
+    // A pack without rows says so; a plugin's page has no rows section; a
+    // built-in pack retries but never uninstalls.
+    fireEvent.click(screen.getByRole('button', { name: 'View no-rows' }))
     expect(screen.getByText(en.partsEmpty)).toBeTruthy()
-    expect(screen.queryByRole('button', { name: en.partsShowAll })).toBeNull()
-    // The count line names only the states that occur.
-    fireEvent.click(screen.getByRole('button', { name: 'Show all-off' }))
-    expect(screen.getByText('1 total · 1 off')).toBeTruthy()
-    expect(screen.queryByRole('button', { name: en.partsShowAll })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Show tool-foo' }))
-    expect(screen.getByText(en.sourceLocal)).toBeTruthy()
-    expect(screen.queryByText(en.partsEmpty)).toBeNull()
-    // Only a pack with failing components offers a retry; a built-in one retries but never uninstalls.
+    expect(screen.getByText(en.noDescription)).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: 'View tool-foo' }))
+    expect(screen.queryByText(en.partsLabel)).toBeNull()
     expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Show Core' }))
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: 'View Core' }))
     expect(screen.getByText('1 total · 1 running')).toBeTruthy()
     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()
+    // A package that leaves the list while its page is open drops back to the cards.
+    set({ packages: [] })
+    expect(screen.getByText(en.empty)).toBeTruthy()
   })
 
-  it('switches the components of a live external pack, and locks what the pack itself keeps off', () => {
+  it('switches the rows of a live external pack, filters a long list, and locks what the pack itself keeps off', () => {
     const userOff = { enabled: false, disabledBy: 'user', phase: null } as const
+    const many = Array.from({ length: 11 }, (_, index) => ({
+      entryId: `include:row-${String(index)}`, rowId: `row-${String(index)}`, moduleName: 'many', enabled: true, phase: 'active' as const,
+    }))
     const { actions, set } = renderTab({
       packages: [
         pkg({
@@ -276,14 +272,15 @@ describe('PluginManagerSettingsTab', () => {
             { entryId: 'conflict:x:taken', rowId: 'taken', moduleName: 'dsh-better-sidebar/taken', enabled: true, phase: null, failure: { stage: 'conflict', message: 'x owns taken' } },
           ],
         }),
+        pkg({ name: 'many', rows: many }),
         pkg({ name: 'frozen', liveReload: false, rows: [{ entryId: 'include:frozen', rowId: 'frozen', moduleName: 'frozen', ...userOff }] }),
         pkg({ name: 'parked', enabled: false, status: 'disabled', rows: [{ entryId: 'include:parked', rowId: 'parked', moduleName: 'parked', ...userOff }] }),
         pkg({ name: '@deepseek-ai/dsh-core', title: 'Core', trust: 'builtin', rows: [{ entryId: 'include:core', rowId: 'core', moduleName: '@deepseek-ai/dsh-core', ...userOff }] }),
       ],
     })
     const target = { kind: 'global' } as const
-    fireEvent.click(screen.getByRole('button', { name: 'Show better-sidebar' }))
-    // A component the user switched off switches back on; one the pack itself keeps off is locked and says why.
+    fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
+    // A row the person switched off switches back on; one the pack itself keeps off is locked and says why.
     const off = screen.getByRole('switch', { name: 'Enable component off' })
     expect(off.getAttribute('aria-checked')).toBe('false')
     fireEvent.click(off)
@@ -291,31 +288,94 @@ describe('PluginManagerSettingsTab', () => {
     const gated = screen.getByRole('switch', { name: 'Enable component gated' })
     expect(gated).toHaveProperty('disabled', true)
     expect(gated.getAttribute('title')).toBe(en.partLockedByComposition)
-    // A failing component can be switched off; a row another layer owns has nothing mounted to switch.
+    // A failing row can be switched off; a row another layer owns has nothing mounted to switch.
     fireEvent.click(screen.getByRole('switch', { name: 'Enable component crash' }))
     expect(actions.setRowDisabled).toHaveBeenLastCalledWith(target, 'crash', true)
     expect(screen.queryByRole('switch', { name: 'Enable component taken' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: en.partsShowAll }))
     const sidebar = screen.getByRole('switch', { name: 'Enable component better-sidebar' })
     expect(sidebar.getAttribute('aria-checked')).toBe('true')
     fireEvent.click(sidebar)
     expect(actions.setRowDisabled).toHaveBeenLastCalledWith(target, 'better-sidebar', true)
-    // Only the row with a write in flight goes inert; a busy package takes every component with it.
+    // Only the row with a write in flight goes inert; a busy package takes every row with it.
     set({ busy: [rowKey(target, 'better-sidebar')] })
     expect(screen.getByRole('switch', { name: 'Enable component better-sidebar' })).toHaveProperty('disabled', true)
     expect(screen.getByRole('switch', { name: 'Enable component off' })).toHaveProperty('disabled', false)
     set({ busy: ['dsh-better-sidebar'] })
     expect(screen.getByRole('switch', { name: 'Enable component off' })).toHaveProperty('disabled', true)
-    fireEvent.click(screen.getByRole('button', { name: 'Hide better-sidebar' }))
-    // Components stay read-only where a switch would not act now: a profile that
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    // A long list gets a filter over the row ids.
+    fireEvent.click(screen.getByRole('button', { name: 'View many' }))
+    const filter = screen.getByRole('searchbox', { name: en.partsFilter })
+    expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(11)
+    fireEvent.change(filter, { target: { value: 'ROW-1' } })
+    expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(2)
+    fireEvent.change(filter, { target: { value: 'nope' } })
+    expect(screen.getByText(en.partsFilterEmpty)).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    // Rows stay read-only where a switch would not act now: a profile that
     // applies patches at its next start, a pack that is off, and a built-in pack.
     for (const [name, rowId] of [['frozen', 'frozen'], ['parked', 'parked'], ['Core', 'core']] as const) {
-      fireEvent.click(screen.getByRole('button', { name: `Show ${name}` }))
-      expect(document.querySelector(`[data-plugin-row="include:${rowId}"]`)?.textContent).toBe(rowId)
+      fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
+      expect(document.querySelector(`[data-plugin-row="include:${rowId}"]`)?.textContent).toBe(`${rowId}${en.partDisabledByUser}`)
       expect(screen.queryByRole('switch', { name: `Enable component ${rowId}` })).toBeNull()
+      fireEvent.click(screen.getByRole('button', { name: en.backToList }))
     }
   })
 
+  it('opens a plugin\'s page with its modules, where each is composed, and an Add to… per module', () => {
+    const { actions } = renderTab({
+      packages: [
+        pkg({
+          name: 'dsh-tool-foo', kind: 'plugin', status: 'plain', title: 'Foo tools', reason: 'one module refused',
+          addable: [
+            { moduleName: 'dsh-tool-foo', declaredName: '.', ok: true },
+            { moduleName: 'dsh-tool-foo/bar', declaredName: 'bar', title: 'Bar', ok: true },
+            { moduleName: 'dsh-tool-foo/broken', declaredName: 'broken', ok: false, error: 'no default export' },
+            { moduleName: 'dsh-tool-foo/silent', declaredName: 'silent', ok: false },
+          ],
+        }),
+        pkg({ name: 'dsh-tool-bare', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'dsh-tool-bare', declaredName: '.', ok: true }] }),
+      ],
+      presets: [
+        preset({ rows: [{ entryId: 'preset:foo', moduleName: 'dsh-tool-foo', source: 'user', enabled: true, fiberPhase: null }] }),
+        preset({ id: 'other', name: 'Other', isDefault: false }),
+      ],
+      globalModules: ['dsh-tool-foo'],
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'View Foo tools' }))
+    expect(screen.getByText(en.statusProblem)).toBeTruthy()
+    expect(screen.getByText(`${en.reasonLabel}: one module refused`)).toBeTruthy()
+    // The main export reads by the package title; a module by its own title;
+    // each names where it is composed, or why it cannot be.
+    const moduleText = (name: string): string | undefined => document.querySelector(`[data-plugin-module="${name}"]`)?.textContent
+    expect(moduleText('dsh-tool-foo')).toContain(`Foo tools${en.moduleJoined.replace('{targets}', `${en.joinedGlobal}, 标准`)}`)
+    expect(moduleText('dsh-tool-foo/bar')).toContain(`Bar${en.moduleNotJoined}`)
+    expect(moduleText('dsh-tool-foo/broken')).toContain(en.moduleBroken.replace('{error}', 'no default export'))
+    expect(moduleText('dsh-tool-foo/silent')).toContain(en.moduleBroken.replace('{error}', ''))
+    // Only an importable module offers Add to…; the menu marks the targets it joined.
+    const menus = screen.getAllByRole('button', { name: en.addTo })
+    expect(menus).toHaveLength(2)
+    fireEvent.click(menus[0] as HTMLElement)
+    expect(screen.getByRole('menuitem', { name: en.addToGlobalAdded })).toBeTruthy()
+    expect(screen.getByRole('menuitem', { name: en.addToPresetAdded.replace('{name}', '标准') })).toBeTruthy()
+    fireEvent.click(screen.getByRole('menuitem', { name: en.addToPreset.replace('{name}', 'Other') }))
+    expect(actions.addRow).toHaveBeenLastCalledWith('dsh-tool-foo', '.', { kind: 'preset', preset: 'other' })
+    fireEvent.click(menus[1] as HTMLElement)
+    fireEvent.click(screen.getByRole('menuitem', { name: en.addToGlobal }))
+    expect(actions.addRow).toHaveBeenLastCalledWith('dsh-tool-foo', 'bar', { kind: 'global' })
+    // The anchor closes its own menu; so does Escape.
+    fireEvent.click(menus[1] as HTMLElement)
+    fireEvent.click(menus[1] as HTMLElement)
+    expect(screen.queryByRole('menuitem', { name: en.addToGlobal })).toBeNull()
+    fireEvent.click(menus[1] as HTMLElement)
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryByRole('menuitem', { name: en.addToGlobal })).toBeNull()
+    // A package without a title reads by its short name.
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: 'View tool-bare' }))
+    expect(moduleText('dsh-tool-bare')).toContain(`tool-bare${en.moduleNotJoined}`)
+  })
+
   it('offers Add to… for a plugin with importable modules, marking the targets it already joined', () => {
     const { actions } = renderTab({
       packages: [

Vissa filer visades inte eftersom för många filer har ändrats