Browse Source

refactor(web): drop the plugin-list dot for an active fiber

A collapsed plugin card carried both an enablement tag and a green root-fiber
status dot; for the common `active` phase the two stated the same settled
state. The dot now renders only for `pending`, `loading` and `unloading`,
the phases the enablement tag cannot state, and the expanded card keeps the
phase fact.

Related to #4424
Turtle 1 month ago
parent
commit
f91d5e310d

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.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/simplification/2026-09-17-plugin-inventory-phase-dot.md
+2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad
+2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md

@@ -0,0 +1,35 @@
+# Agent Note: The plugin inventory reserves its status dot for phases the tag cannot state
+
+Status: implemented
+
+English | [中文](2026-09-17-plugin-inventory-phase-dot.zh.md)
+
+## Problem
+
+The Settings plugin list marks every Loader entry with an enablement tag (`已启用`, `已停用`, `条件启用`, `预设中启用`, `启动失败`). It also drew a colored root-fiber status dot on enabled rows whose fiber was live. For the common `active` phase the dot and the tag stated the same thing twice on one collapsed card, while `pending`, `loading` and `unloading` — the phases the tag has no words for — were where the dot carried its own information.
+
+## Decision
+
+`PluginInventorySettingsTab` renders the status dot only for `pending`, `loading` and `unloading`. `active` and `failed` render no dot: a settled enabled row shows its enablement tag alone, and a failed row has the failure tag and card treatment. `PHASE_DOT_STATES` lists exactly the dotted phases, and `showsPhaseDot` narrows a phase to that union.
+
+Row enablement, tag tones, grouping, search and list behavior are unchanged, as are the Loader lifecycle and persistence. The expanded card still lists the phase under 运行状态 / Status, so the runtime phase stays readable without the collapsed dot.
+
+## Alternatives considered
+
+**Remove the dot for every phase.** Simpler to state, but it drops the only collapsed-state signal that a plugin is still loading or unloading — the one state the tag cannot express.
+
+**Keep the `active` dot on preset rows only.** The preset group is where the duplication was first noticed, but the global group renders the same tag-and-dot pair. A per-group exception would make one rendering rule depend on which group a row sits in, and would leave the duplicate in the global group.
+
+**Fold the phase into the enablement tag.** The tag reports the config fact the Host publishes as `enabled`; adding a runtime phase would make one control carry two lifecycles with different owners.
+
+## Consequences
+
+- A collapsed card no longer shows that an enabled entry's fiber is currently `active`. A reader who needs the phase expands the row, whose Status fact keeps it. That is the information the change deliberately gives up.
+- Rows mid-transition keep their dot, its animation and its `role="img"` name, so the accessible name still says `等待依赖`, `加载中` or `卸载中`.
+- Known gap, pre-existing and unchanged: the dot is gated on `row.enabled`, so a row a user just disabled while its fiber is still `unloading` shows `已停用` without a dot.
+
+## Testing
+
+The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`.
+
+The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md).

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 插件列表只为主题标签无法表达的阶段保留状态圆点
+
+Status: implemented
+
+[English](2026-09-17-plugin-inventory-phase-dot.md) | 中文
+
+## 问题
+
+设置里的插件列表给每个 Loader 条目配一个启停标签(`已启用`、`已停用`、`条件启用`、`预设中启用`、`启动失败`),同时给 fiber 存活的已启用条目画一个彩色根 fiber 状态圆点。对最常见的 `active` 阶段,圆点与标签在同一张收起卡片上把同一件事说了两遍;而标签没有词可用的 `pending`、`loading`、`unloading`,才是圆点真正携带独立信息的阶段。
+
+## 决策
+
+`PluginInventorySettingsTab` 只为 `pending`、`loading`、`unloading` 渲染状态圆点。`active` 与 `failed` 不渲染:已稳定启用的条目只显示启停标签,失败条目由失败标签和卡片样式说明。`PHASE_DOT_STATES` 只列出有圆点的阶段,`showsPhaseDot` 把阶段收窄到该联合。
+
+条目启停、标签色调、分组、搜索与列表行为不变,Loader 生命周期与持久化也不变。展开卡片仍在「运行状态」下给出阶段名,因此不画收起态圆点也能读到运行时阶段。
+
+## 否决的备选方案
+
+**所有阶段都不画圆点。** 规则更简单,但会丢掉收起态下唯一能说明插件正在加载或卸载的信号——恰恰是标签无法表达的那个状态。
+
+**只在预设分组保留 `active` 圆点。** 重复最先是在预设分组被注意到的,但全局分组渲染的是同一套标签加圆点。按分组开例外会让同一条渲染规则取决于条目落在哪个分组,而且全局分组的重复仍然存在。
+
+**把阶段并进启停标签。** 标签报告的是 Host 以 `enabled` 发布的配置事实;再塞一个运行时阶段会让一个控件承载两条归属不同的生命周期。
+
+## 后果
+
+- 收起卡片不再显示已启用条目的 fiber 当前处于 `active`。需要该阶段的读者展开条目,其「运行状态」事实仍然给出。这是本次变更刻意放弃的信息。
+- 过渡中的条目保留圆点、动画与 `role="img"` 名称,无障碍名称仍会读出 `等待依赖`、`加载中` 或 `卸载中`。
+- 既有且未改变的缺口:圆点以 `row.enabled` 为前提,用户刚停用而 fiber 仍在 `unloading` 的条目只显示 `已停用`,没有圆点。
+
+## 测试
+
+包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。
+
+其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。

+ 6 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -126,6 +126,12 @@ describe('web e2e: settings modal and General preferences', () => {
     // presets took over included, preset compositions excluded.
     expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
       .toBe(expectedPluginCount)
+    // The enablement tag is the row's collapsed status: an active fiber draws no
+    // dot, so no global row names the active phase. Guard the assertion against
+    // matching nothing because no row is enabled.
+    expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count())
+      .toBeGreaterThan(0)
+    expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0)
     expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
       .toBe(String(expectedPluginCount))
     expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true')

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

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

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

@@ -31,7 +31,7 @@ A failed installation with pending pnpm build permissions offers **Allow these s
 
 ### Reading a card
 
-Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; enabled entries also show a colored root-fiber status dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
+Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; a colored root-fiber status dot marks only the `pending`, `loading`, and `unloading` phases the tag cannot state, so an `active` fiber shows its tag alone. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
 
 ### The preset switcher
 

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

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 ### 阅读卡片
 
-每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
+每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;彩色根 fiber 状态圆点只标记标签无法表达的 `pending`、`loading`、`unloading` 阶段,`active` 的条目只显示标签。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
 
 ### 预设切换器
 

+ 17 - 9
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -164,19 +164,27 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
   )
 }
 
-/* `pending` is the only phase with no work under way. `loading` and
+/* `pending` is the only dotted phase with no work under way. `loading` and
  * `unloading` are both live transitions the Host is running — an async
- * disposer can hold `unloading` for a while — so both animate. */
+ * disposer can hold `unloading` for a while — so both animate. `active` and
+ * `failed` carry no dot: a settled enabled row shows its enablement tag alone,
+ * and a failed row has the failure tag. */
 const PHASE_DOT_STATES = {
   pending: 'idle',
   loading: 'ongoing',
-  active: 'done',
-  failed: 'error',
   unloading: 'ongoing',
-} as const satisfies Record<NonNullable<PluginFiberPhase>, StateDotState>
+} as const satisfies Partial<Record<NonNullable<PluginFiberPhase>, StateDotState>>
 
-/** Status dot naming a live root-fiber phase; rows with no live fiber show none. */
-function PhaseDot({ phase, t }: { readonly phase: NonNullable<PluginFiberPhase>; readonly t: Translate }): ReactNode {
+/** A live root-fiber phase whose dot still adds to the row's enablement tag. */
+type DotPhase = keyof typeof PHASE_DOT_STATES
+
+/** Whether a live root-fiber phase carries a dot of its own. */
+function showsPhaseDot(phase: PluginFiberPhase): phase is DotPhase {
+  return phase === 'pending' || phase === 'loading' || phase === 'unloading'
+}
+
+/** Status dot naming a live root-fiber phase; rows without a dotted phase show none. */
+function PhaseDot({ phase, t }: { readonly phase: DotPhase; readonly t: Translate }): ReactNode {
   const status = phaseLabel(phase, t)
   /* StateDot is aria-hidden, so the phase name lives on this wrapper. */
   return (
@@ -301,7 +309,7 @@ export function PluginInventorySettingsTab(
         ariaLabel={`${title}${row.entryId === null ? '' : `, ${row.entryId}`}, ${stateText}`}
         trailing={(
           <>
-            {row.enabled === true && !failed && row.fiberPhase !== null
+            {row.enabled === true && showsPhaseDot(row.fiberPhase)
               ? <PhaseDot phase={row.fiberPhase} t={t} />
               : null}
             <StateTag kind={kind} label={stateText} />
@@ -347,7 +355,7 @@ export function PluginInventorySettingsTab(
         ariaLabel={`${title}, ${entry.entryId}, ${stateText}`}
         trailing={(
           <>
-            {entry.enabled && !failed && entry.fiberPhase !== null
+            {entry.enabled && showsPhaseDot(entry.fiberPhase)
               ? <PhaseDot phase={entry.fiberPhase} t={t} />
               : null}
             <StateTag kind={kind} label={stateText} />

+ 33 - 3
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -116,9 +116,10 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.conditionalTag)).toBeTruthy()
     expect(screen.getByText(en.disabledTag)).toBeTruthy()
     expect(screen.getByText(en.failedTag)).toBeTruthy()
-    expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
-    // No live fiber, no dot: file-state rows carry only their enablement tag.
-    expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
+    // The enablement tag is the row's one settled status signal: an active fiber
+    // and a row with no live fiber both render without a phase dot.
+    expect(screen.queryByRole('img', { name: en.active })).toBeNull()
+    expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull()
 
     expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
     expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
@@ -144,6 +145,35 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
   })
 
+  it('keeps the phase dot for a live phase the enablement tag does not state', async () => {
+    await renderReady({
+      entries: [
+        { entryId: 'booting', moduleName: '@fixture/booting', enabled: true, fiberPhase: 'loading' },
+        { entryId: 'waiting', moduleName: '@fixture/waiting', enabled: true, fiberPhase: 'pending' },
+        { entryId: 'running', moduleName: '@fixture/running', enabled: true, fiberPhase: 'active' },
+        { entryId: 'unobserved', moduleName: '@fixture/unobserved', enabled: true, fiberPhase: null },
+      ],
+      agentPresets: [{
+        id: 'standard',
+        trust: 'system',
+        isDefault: true,
+        rows: [
+          { entryId: 'stopping', moduleName: '@fixture/stopping', enabled: true, fiberPhase: 'unloading' },
+          { entryId: 'preset-running', moduleName: '@fixture/preset-running', enabled: true, fiberPhase: 'active' },
+        ],
+      }],
+    } as unknown as Snapshot)
+
+    fireEvent.click(globalToggle())
+    fireEvent.click(presetToggle())
+    expect(screen.getAllByText(en.enabledTag)).toHaveLength(6)
+    expect(screen.getByRole('img', { name: en.loadingPhase })).toBeTruthy()
+    expect(screen.getByRole('img', { name: en.pending })).toBeTruthy()
+    expect(screen.getByRole('img', { name: en.unloading })).toBeTruthy()
+    expect(screen.queryByRole('img', { name: en.active })).toBeNull()
+    expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull()
+  })
+
   it('distinguishes collapsed same-module rows by stable entry id', async () => {
     const longId = 'include:agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
     const subtitle = 'agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'