Просмотр исходного кода

feat(web): count line and exceptions first for a pack's components

A plugin pack's expanded card no longer lists every component as a row:
a count line says how many and how many are running, off, or failing;
only the components that are off or failing get a line, each with why;
the rest sit as chips behind Show all with a filter. base carries close
to a hundred rows, of which three are off on macOS — those three are the
ones worth reading.
Yichen Jiang 1 месяц назад
Родитель
Сommit
e9c5a9d2f1

+ 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: 2dcb93f680bf6cd7fb8aa421ebbadb0438a1302c
-README.zh.md: 3781fe530fb990b8640841c18944473d5bd6239d
+README.md: 5db176df6151a5bfe000647be5b89565951a000c
+README.zh.md: ce99a9e2e460bae967eea92fa470c91ffea55f6f

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns without showing them: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per package with its display name and its one-line description; a pack carries its switch, a plugin its **Add to…** menu, a built-in pack a *Built-in* mark and a locked switch, and a card is tagged only when a restart is pending or something is wrong. An expanded card shows the version, the source, a pack's components with their state, and **Uninstall**; entry ids, module names, kinds, and probe facts stay off the page. A preset's composition is not on this tab: the package contributes it to the preset's detail page in the Agent presets section as the **Capabilities** section — the same cards, harness modules named through the tab's dictionary and installed ones through their manifest with a *Local* mark, a switch per card, **Delete** for rows the user added, and an **Add** menu. The install dialog takes a package name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
+`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The page speaks two nouns without showing them: a *plugin pack* (a bundle) switches on and off as a whole and serves every session; a *plugin* (a plugin module) joins a preset or every session through **Add to…**. The **Installed** group lists one card per package with its display name and its one-line description; a pack carries its switch, a plugin its **Add to…** menu, a built-in pack a *Built-in* mark and a locked switch, and a card is tagged only when a restart is pending or something is wrong. An expanded card shows the version, the source, a pack's components — a count line, then only the components that are off or failing with why, the rest as chips behind **Show all** with a filter — and **Uninstall**; entry ids, module names, kinds, and probe facts stay off the page. A preset's composition is not on this tab: the package contributes it to the preset's detail page in the Agent presets section as the **Capabilities** section — the same cards, harness modules named through the tab's dictionary and installed ones through their manifest with a *Local* mark, a switch per card, **Delete** for rows the user added, and an **Add** menu. The install dialog takes a package name, a local path, or a Git URL, keeps pnpm's output behind a fold that opens on failure, and words the outcome per package. A disable with dependents and every uninstall wait for a confirmation that names what still uses the package. Without a profile runtime the tab says it is unavailable and offers nothing.
 
 ## Table of Contents
 

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词但不把它们显示出来:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个包列出一张卡片,带显示名与一句话描述;插件包带开关,插件带**加入到…**菜单,内置插件包带*内置*标记与锁定的开关,只有需重启或异常时卡片才打标签。展开的卡片显示版本、来源、插件包的组件及各自状态,以及**卸载**;entry id、模块名、种类与探针事实不出现在页面上。预设的组合不在这个标签页上:本包把它作为**能力**段贡献到 Agent 预设分区里该预设的详情页——同样的卡片,harness 模块按标签页字典命名,自己装的模块按 manifest 命名并带*本地*标记,每张卡片一个开关,用户添加的行带**删除**,段头有**添加**菜单。安装对话框接受包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
+`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。页面只说两个名词但不把它们显示出来:*插件包*(bundle)整体启停、对所有会话生效;*插件*(插件模块)经**加入到…**加入某个预设或所有会话。**已安装**组为每个包列出一张卡片,带显示名与一句话描述;插件包带开关,插件带**加入到…**菜单,内置插件包带*内置*标记与锁定的开关,只有需重启或异常时卡片才打标签。展开的卡片显示版本、来源、插件包的组件——一行计数,然后只列未启用或异常的组件并注明原因,其余作为标签收在**显示全部**之后并可筛选——以及**卸载**;entry id、模块名、种类与探针事实不出现在页面上。预设的组合不在这个标签页上:本包把它作为**能力**段贡献到 Agent 预设分区里该预设的详情页——同样的卡片,harness 模块按标签页字典命名,自己装的模块按 manifest 命名并带*本地*标记,每张卡片一个开关,用户添加的行带**删除**,段头有**添加**菜单。安装对话框接受包名、本地路径或 Git 地址,把 pnpm 输出收在失败时自动展开的折叠里,并按包用一句话说明结果。有依赖方的停用与每次卸载都要等待一次确认,确认框点名还在使用该包的地方。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
 
 ## 目录
 

+ 86 - 11
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css

@@ -157,7 +157,8 @@
 }
 
 .cards,
-.parts {
+.partExceptions,
+.chips {
   margin: 0;
   padding: 0;
   list-style: none;
@@ -481,38 +482,112 @@
 .parts {
   display: flex;
   flex-direction: column;
-  gap: 4px;
+  gap: 8px;
 }
 
-.part {
+.partsHead {
   display: flex;
   align-items: center;
-  flex-wrap: wrap;
-  gap: 8px;
-  min-height: 28px;
-  padding: 4px 8px;
+  gap: 10px;
+}
+
+.partsHead .subLabel {
+  margin: 0;
+}
+
+.partsCount {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.partsHead .linkButton {
+  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;
 }
 
-.partName {
-  flex: 1;
-  min-width: 0;
+.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;
+  color: var(--dsw-alias-label-tertiary);
 }
 
 .partFailure {
-  flex-basis: 100%;
+  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: 180px;
+  height: 28px;
+  padding: 0 10px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-3);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 12px;
+}
+
+.partsFilter:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+.chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 24px;
+  padding: 0 9px 0 8px;
+  border-radius: 999px;
+  corner-shape: round;
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-secondary);
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+  font-size: 11.5px;
+}
+
 .statusDot {
   display: inline-block;
   width: 7px;

+ 97 - 23
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -67,6 +67,102 @@ function Tag({ kind, children }: { readonly kind: string; readonly children: Rea
   return <span className={css.tag} data-kind={kind}>{children}</span>
 }
 
+/** A component with a recorded startup failure or a failed fiber. */
+function isFailedRow(row: RowView): boolean {
+  return row.failure !== undefined || row.phase === 'failed'
+}
+
+/** The count line over a pack's components: the total, then only the states that occur. */
+function partsSummary(rows: readonly RowView[], t: Translate): string {
+  const failed = rows.filter(isFailedRow).length
+  const off = rows.filter(row => !row.enabled && !isFailedRow(row)).length
+  const running = rows.filter(row => row.enabled && row.phase === 'active').length
+  return [
+    t('partsCountTotal', { count: String(rows.length) }),
+    ...running > 0 ? [t('partsCountRunning', { count: String(running) })] : [],
+    ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [],
+    ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [],
+  ].join(' · ')
+}
+
+/**
+ * The components of one plugin pack: a count line, the components that are
+ * off or failing with why, and the rest as chips behind **Show all** with a
+ * filter — a pack like base carries close to a hundred rows, of which the
+ * few that are not running are the ones worth a line each.
+ */
+function BundleParts({ rows, t }: { readonly rows: readonly RowView[]; readonly t: Translate }): ReactNode {
+  const [open, setOpen] = useState(false)
+  const [filter, setFilter] = useState('')
+  const exceptions = rows.filter(row => isFailedRow(row) || !row.enabled)
+  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))
+  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
+              type="button"
+              className={css.linkButton}
+              aria-expanded={open}
+              onClick={() => { setOpen(current => !current) }}
+            >
+              {t(open ? 'partsCollapse' : 'partsShowAll')}
+            </button>
+          )}
+      </div>
+      {rows.length === 0 ? <p className={css.status}>{t('partsEmpty')}</p> : null}
+      {exceptions.length === 0
+        ? null
+        : (
+          <ul className={css.partExceptions}>
+            {exceptions.map(row => (
+              <li key={row.entryId} className={css.partException} data-plugin-row={row.entryId}>
+                <span className={css.statusDot} data-phase={isFailedRow(row) ? 'failed' : 'off'} aria-hidden="true" />
+                <span className={css.partId}>{row.rowId}</span>
+                {isFailedRow(row)
+                  ? <Tag kind="problem">{t('rowStateFailed')}</Tag>
+                  : <span className={css.partWhy}>{t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')}</span>}
+                {row.failure === undefined ? null : <p className={css.partFailure}>{row.failure.message}</p>}
+              </li>
+            ))}
+          </ul>
+        )}
+      {open
+        ? (
+          <div className={css.partsAll}>
+            <input
+              type="search"
+              className={css.partsFilter}
+              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 => (
+                    <li key={row.entryId} className={css.chip} data-plugin-row={row.entryId}>
+                      {row.phase === null ? null : <PhaseDot phase={row.phase} t={t} />}
+                      {row.rowId}
+                    </li>
+                  ))}
+                </ul>
+              )}
+          </div>
+        )
+        : null}
+    </div>
+  )
+}
+
 function Switch({ checked, label, disabled, title, onChange }: {
   readonly checked: boolean
   readonly label: string
@@ -250,29 +346,7 @@ function PackageCard({
               <dt>{t('sourceLabel')}</dt>
               <dd>{t(builtin ? 'sourceBuiltin' : 'sourceLocal')}</dd>
             </dl>
-            {bundle
-              ? (
-                <>
-                  <p className={css.subLabel}>{t('partsLabel')}</p>
-                  {pkg.rows.length === 0
-                    ? <p className={css.status}>{t('partsEmpty')}</p>
-                    : (
-                      <ul className={css.parts}>
-                        {pkg.rows.map(row => (
-                          <li key={row.entryId} className={css.part} data-plugin-row={row.entryId}>
-                            {row.enabled && row.phase !== null ? <PhaseDot phase={row.phase} t={t} /> : null}
-                            <span className={css.partName}>{row.rowId}</span>
-                            {row.enabled
-                              ? null
-                              : <Tag kind="off">{t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')}</Tag>}
-                            {row.failure === undefined ? null : <p className={css.partFailure}>{row.failure.message}</p>}
-                          </li>
-                        ))}
-                      </ul>
-                    )}
-                </>
-              )
-              : null}
+            {bundle ? <BundleParts rows={pkg.rows} t={t} /> : null}
             {retryable || removable
               ? (
                 <div className={css.actions}>

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

@@ -39,8 +39,16 @@ export const zh = {
   sourceLocal: '本地',
   partsLabel: '包含的组件',
   partsEmpty: '这个插件包不包含任何组件。',
-  partDisabledByUser: '已停用',
-  partDisabledByComposition: '未启用',
+  partsCountTotal: '共 {count} 个',
+  partsCountRunning: '{count} 运行中',
+  partsCountOff: '{count} 未启用',
+  partsCountFailed: '{count} 异常',
+  partsShowAll: '显示全部',
+  partsCollapse: '收起',
+  partsFilter: '筛选组件',
+  partsFilterEmpty: '没有匹配的组件。',
+  partDisabledByUser: '已停用 · 你关闭的',
+  partDisabledByComposition: '未启用 · 插件包自身关闭',
   rowPhasePending: '等待依赖',
   rowPhaseLoading: '加载中',
   rowPhaseActive: '运行中',
@@ -214,8 +222,16 @@ export const en = {
   sourceLocal: 'Local',
   partsLabel: 'Components',
   partsEmpty: 'This plugin pack contains no components.',
-  partDisabledByUser: 'Off',
-  partDisabledByComposition: 'Not enabled',
+  partsCountTotal: '{count} total',
+  partsCountRunning: '{count} running',
+  partsCountOff: '{count} off',
+  partsCountFailed: '{count} failed',
+  partsShowAll: 'Show all',
+  partsCollapse: 'Collapse',
+  partsFilter: 'Filter components',
+  partsFilterEmpty: 'No component matches.',
+  partDisabledByUser: 'Off · switched off by you',
+  partDisabledByComposition: 'Not enabled · off in the pack itself',
   rowPhasePending: 'Waiting for dependencies',
   rowPhaseLoading: 'Loading',
   rowPhaseActive: 'Running',

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

@@ -184,23 +184,48 @@ describe('PluginManagerSettingsTab', () => {
             { entryId: 'include:off', rowId: 'off', moduleName: 'dsh-better-sidebar/off', enabled: false, disabledBy: 'user', phase: null },
             { entryId: 'include:gated', rowId: 'gated', moduleName: 'dsh-better-sidebar/gated', enabled: false, disabledBy: 'composition', phase: null },
             { entryId: 'include:crash', rowId: 'crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } },
+            { entryId: 'include:flaky', rowId: 'flaky', moduleName: 'dsh-better-sidebar/flaky', enabled: true, phase: 'failed' },
+            { entryId: 'include:idle', rowId: 'idle', moduleName: 'dsh-better-sidebar/idle', enabled: true, phase: null },
           ],
         }),
         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' }),
+        pkg({
+          name: '@deepseek-ai/dsh-core-broken', title: 'Core', trust: 'builtin', status: 'failed',
+          rows: [{ entryId: 'include:core', rowId: 'core', moduleName: '@deepseek-ai/dsh-core-broken', enabled: true, phase: 'active' }],
+        }),
       ],
     })
     fireEvent.click(screen.getByRole('button', { name: 'Show better-sidebar' }))
     expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
     expect(screen.getByText('0.16.0')).toBeTruthy()
     expect(screen.getByText(en.sourceLocal)).toBeTruthy()
-    expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy()
-    // A component shows the id its pack declared, not the tree-wide entry id.
-    expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')?.textContent).toContain('better-sidebar')
+    // The count line, then only the components that are off or failing, each
+    // with why; the running ones wait behind Show all.
+    expect(screen.getByText('6 total · 1 running · 2 off · 2 failed')).toBeTruthy()
+    expect(document.querySelector('[data-plugin-row="include:better-sidebar"]')).toBeNull()
     expect(document.querySelector('[data-plugin-row="include:off"]')?.textContent).toContain(en.partDisabledByUser)
     expect(document.querySelector('[data-plugin-row="include:gated"]')?.textContent).toContain(en.partDisabledByComposition)
+    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')
+    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()
 
     fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
     expect(actions.retry).toHaveBeenCalledWith('dsh-better-sidebar')
@@ -217,12 +242,18 @@ describe('PluginManagerSettingsTab', () => {
     // and opening one card closes the other.
     fireEvent.click(screen.getByRole('button', { name: 'Show 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.queryByRole('button', { name: en.retryPackage })).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: 'Show 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()