Browse Source

feat(web): read a row's wait for a service as one state

A row waiting for a service another row provides reads the same whether a
live recomposition left its fiber pending or a fresh composition recorded
the wait as an inject-pending failure: waiting for a dependency, on the
row, in the count line, and on the pack's tag in warning tone, with the
pack's reason line left to the row that explains it; the red problem is
kept for a real failure. A module's note about where it is composed now
wraps under its name, so a long list of presets no longer squeezes the
name into a column, and the module's own Add to… is all that sits at the
right.
Yichen Jiang 1 month ago
parent
commit
6148b6ddba

+ 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: 6d274be807b3512460c3e3dea834658b2a417c30
-2026-09-04-plugin-management-in-web-settings.zh.md: 9e480e91c3f4ecb0ff7b9fbdc876806ae988b221
+2026-09-04-plugin-management-in-web-settings.md: 83b4f6fe36b720fb7a57538a53bc3e8c46d09f30
+2026-09-04-plugin-management-in-web-settings.zh.md: 4a1530592e34df647f1ec6a9900f31e3ba0a5853

File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md


File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md


+ 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: c3f69d8c9e351464044b128ab7f1f1f09d02ae96
-README.zh.md: 2ec0e4f20bc24c49ba7f3396306adc23d462cf8c
+README.md: 4dd556e43edc20211b0fab4f091426a52542316a
+README.zh.md: 20cef6d519c2ce44c7f2d0d72518b98b7e8f5f5c

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

@@ -45,7 +45,7 @@ A plugin's card carries **Add to…**, listing every session and each preset; a
 
 ### Reading a failure
 
-What the last action left to say sits above the groups: a restart notice, or the failure with the Host's own reason — a probe refusal, a rejected recomposition, a row id already taken. Dismiss it or let the next action replace it.
+What the last action left to say sits above the groups: a restart notice, or the failure with the Host's own reason — a probe refusal, a rejected recomposition, a row id already taken. Dismiss it or let the next action replace it. A row waiting for a service another row provides — one the person switched off, say — reads as *waiting for a dependency* on the row and on its pack's tag, whether a live recomposition left its fiber pending or a fresh composition recorded the wait as a failure; the red *problem* is kept for a real failure.
 
 -----
 

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

@@ -45,7 +45,7 @@ kind: "package-reference"
 
 ### 阅读失败
 
-上一次操作留下的话停在两组之上:重启提示,或带宿主原因的失败——探针拒绝、被拒的重新组合、已被占用的行 id。可以关掉它,也可以让下一次操作替换它。
+上一次操作留下的话停在两组之上:重启提示,或带宿主原因的失败——探针拒绝、被拒的重新组合、已被占用的行 id。可以关掉它,也可以让下一次操作替换它。等待另一行提供的服务的行——比如那一行被人关掉了——在行上和插件包标签上都读作*等待依赖*,不论是就地重组把 fiber 留在挂起状态,还是全新组合把这次等待记成了失败;红色的*异常*只留给真正的失败。
 
 -----
 

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

@@ -649,6 +649,17 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
+.rowNote {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+  overflow-wrap: anywhere;
+}
+
+.row[data-state='failed'] .rowNote {
+  color: var(--dsw-alias-state-error-primary);
+}
+
 .rowFailure {
   margin: 4px 0 0 44px;
   font-size: 12px;

+ 37 - 17
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -51,8 +51,8 @@ const PHASE_KEYS = {
   unloading: 'rowPhaseUnloading',
 } satisfies Record<RowPhase, PluginManagerLocaleKey>
 
-/** The two states a card tags; running and off are what the switch shows. */
-type CardStatus = 'restart' | 'problem'
+/** The states a card tags; running and off are what the switch shows. */
+type CardStatus = 'restart' | 'waiting' | 'problem'
 
 const STATUS_OF = {
   'running': null,
@@ -66,6 +66,7 @@ const STATUS_OF = {
 
 const STATUS_KEYS = {
   restart: 'statusRestart',
+  waiting: 'statusWaiting',
   problem: 'statusProblem',
 } satisfies Record<CardStatus, PluginManagerLocaleKey>
 
@@ -79,19 +80,37 @@ const PHASE_STATES = {
   unloading: 'idle',
 } satisfies Record<RowPhase, StateDotState>
 
-/** A component with a recorded startup failure or a failed fiber. */
+/**
+ * A row waiting for a service another row provides: a fiber left pending by
+ * a live recomposition, or the failure a fresh composition records for the
+ * same wait. Both read as one state, since the person sees one situation.
+ */
+function isWaitingRow(row: RowView): boolean {
+  return row.failure?.stage === 'inject-pending' || (row.failure === undefined && row.phase === 'pending')
+}
+
+/** A row with a recorded startup failure or a failed fiber; a wait for a service is not a failure. */
 function isFailedRow(row: RowView): boolean {
-  return row.failure !== undefined || row.phase === 'failed'
+  return !isWaitingRow(row) && (row.failure !== undefined || row.phase === 'failed')
+}
+
+/** The tag one package carries: a pending restart, rows waiting for a service, a problem, or none. */
+function cardStatus(pkg: PluginPackageView): CardStatus | null {
+  const base = pkg.kind === 'bundle' ? STATUS_OF[pkg.status] : pkg.reason === undefined ? null : 'problem'
+  if (base === 'problem' && pkg.kind === 'bundle' && pkg.enabled && pkg.rows.some(isWaitingRow) && !pkg.rows.some(isFailedRow)) return 'waiting'
+  return base
 }
 
 /** 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 waiting = rows.filter(isWaitingRow).length
+  const off = rows.filter(row => !row.enabled && !isFailedRow(row) && !isWaitingRow(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) })] : [],
+    ...waiting > 0 ? [t('partsCountWaiting', { count: String(waiting) })] : [],
     ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [],
     ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [],
   ].join(' · ')
@@ -128,6 +147,7 @@ function RowSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Tran
 
 /** 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 (isWaitingRow(row)) return t('rowPhasePending')
   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])
@@ -135,6 +155,7 @@ function rowStateText(row: RowView, t: Translate): string {
 
 /** The dot beside a row: its failure, its fiber phase, or idle. */
 function rowDotState(row: RowView): StateDotState {
+  if (isWaitingRow(row)) return 'ongoing'
   if (isFailedRow(row)) return 'error'
   if (!row.enabled || row.phase === null) return 'idle'
   return PHASE_STATES[row.phase]
@@ -188,7 +209,7 @@ function RowsSection({ rows, t, toggle }: {
                   key={row.entryId}
                   className={css.row}
                   data-plugin-row={row.entryId}
-                  {...isFailedRow(row) ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
+                  {...isWaitingRow(row) ? { 'data-state': 'waiting' } : isFailedRow(row) ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
                 >
                   <div className={css.rowLine}>
                     <span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
@@ -265,13 +286,12 @@ function ModulesSection({ pkg, t, busy, presets, globalModules, presetName, onAd
                 <div className={css.rowMain}>
                   <span className={css.rowId}>{entry.title ?? (entry.declaredName === '.' ? title : entry.declaredName)}</span>
                   <span className={css.rowModule}>{entry.moduleName}</span>
+                  <span className={css.rowNote}>
+                    {entry.ok
+                      ? joined.length > 0 ? t('moduleJoined', { targets: joined.join(t('joinedSeparator')) }) : t('moduleNotJoined')
+                      : t('moduleBroken', { error: entry.error ?? '' })}
+                  </span>
                 </div>
-                <span className={css.rowState}>
-                  <StateDot state={entry.ok ? joined.length > 0 ? 'done' : 'idle' : 'error'} size={8} />
-                  {entry.ok
-                    ? joined.length > 0 ? t('moduleJoined', { targets: joined.join(t('joinedSeparator')) }) : t('moduleNotJoined')
-                    : t('moduleBroken', { error: entry.error ?? '' })}
-                </span>
                 {entry.ok
                   ? (
                     <Menu
@@ -351,7 +371,7 @@ function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen,
   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 status = cardStatus(pkg)
   const addable = pkg.addable.filter(entry => entry.ok)
   const [single] = addable
   const menuItems: MenuItem[] = single !== undefined && addable.length === 1
@@ -368,7 +388,7 @@ function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen,
           <div className={css.titleRow}>
             <span className={css.cardTitle}>{title}</span>
             {builtin ? <Tag>{t('builtinTag')}</Tag> : null}
-            {status === null ? null : <Tag tone={status === 'restart' ? 'warning' : 'danger'}>{t(STATUS_KEYS[status])}</Tag>}
+            {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
           </div>
           {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
         </div>
@@ -456,7 +476,7 @@ function PackageDetail({
   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 status = cardStatus(pkg)
   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
@@ -475,7 +495,7 @@ function PackageDetail({
           <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>}
+            {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
           </div>
           <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
         </div>
@@ -491,7 +511,7 @@ function PackageDetail({
           )
           : null}
       </div>
-      {pkg.reason === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
+      {pkg.reason === undefined || status === 'waiting' ? 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>

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

@@ -31,6 +31,7 @@ export const zh = {
   builtinTag: '内置',
   localTag: '本地',
   statusRestart: '需重启',
+  statusWaiting: '等待依赖',
   statusProblem: '异常',
   reasonLabel: '原因',
   versionLabel: '版本',
@@ -42,6 +43,7 @@ export const zh = {
   partsEmpty: '这个插件包不包含任何组件。',
   partsCountTotal: '共 {count} 个',
   partsCountRunning: '{count} 运行中',
+  partsCountWaiting: '{count} 等待依赖',
   partsCountOff: '{count} 已停用',
   partsCountFailed: '{count} 异常',
   partsFilter: '筛选组件',
@@ -225,6 +227,7 @@ export const en = {
   builtinTag: 'Built-in',
   localTag: 'Local',
   statusRestart: 'Restart needed',
+  statusWaiting: 'Waiting for a dependency',
   statusProblem: 'Problem',
   reasonLabel: 'Reason',
   versionLabel: 'Version',
@@ -236,6 +239,7 @@ export const en = {
   partsEmpty: 'This plugin pack contains no components.',
   partsCountTotal: '{count} total',
   partsCountRunning: '{count} running',
+  partsCountWaiting: '{count} waiting',
   partsCountOff: '{count} off',
   partsCountFailed: '{count} failed',
   partsFilter: 'Filter components',

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

@@ -193,8 +193,14 @@ describe('PluginManagerSettingsTab', () => {
             { 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 },
+            { entryId: 'include:wait', rowId: 'wait', moduleName: 'dsh-better-sidebar/wait', enabled: true, phase: 'pending' },
+            { entryId: 'include:recorded', rowId: 'recorded', moduleName: 'dsh-better-sidebar/recorded', enabled: true, phase: null, failure: { stage: 'inject-pending', message: 'pending (waiting for service: authorization)' } },
           ],
         }),
+        pkg({
+          name: 'waiting-pack', status: 'partial', reason: 'pending (waiting for service: authorization)',
+          rows: [{ entryId: 'include:w1', rowId: 'w1', moduleName: 'waiting-pack', enabled: true, phase: null, failure: { stage: 'inject-pending', message: 'pending (waiting for service: authorization)' } }],
+        }),
         pkg({ name: 'no-rows', enabled: false, status: 'disabled' }),
         pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }),
         pkg({
@@ -203,6 +209,8 @@ describe('PluginManagerSettingsTab', () => {
         }),
       ],
     })
+    // A pack whose only trouble is a row waiting for a service is tagged as waiting, not as a problem.
+    expect(screen.getByText(en.statusWaiting)).toBeTruthy()
     // 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()
@@ -211,7 +219,7 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.getByText('0.16.0')).toBeTruthy()
     expect(screen.getByText(en.sourceExternal)).toBeTruthy()
     // Every row in the pack's order: its id over the module it loads, then its state; a failure adds its message.
-    expect(screen.getByText('6 total · 1 running · 2 off · 2 failed')).toBeTruthy()
+    expect(screen.getByText('8 total · 1 running · 2 waiting · 2 off · 2 failed')).toBeTruthy()
     const rowText = (id: string): string | undefined => document.querySelector(`[data-plugin-row="include:${id}"]`)?.textContent
     expect(rowText('better-sidebar')).toBe(`better-sidebardsh-better-sidebar${en.rowPhaseActive}`)
     expect(rowText('off')).toBe(`offdsh-better-sidebar/off${en.partDisabledByUser}`)
@@ -219,6 +227,11 @@ describe('PluginManagerSettingsTab', () => {
     expect(rowText('crash')).toBe(`crashdsh-better-sidebar/crash${en.rowStateFailed}boom`)
     expect(rowText('flaky')).toBe(`flakydsh-better-sidebar/flaky${en.rowStateFailed}`)
     expect(rowText('idle')).toBe(`idledsh-better-sidebar/idle${en.rowStateIdle}`)
+    // A wait for a service reads the same whether the fiber is pending or a composition recorded the wait.
+    expect(rowText('wait')).toBe(`waitdsh-better-sidebar/wait${en.rowPhasePending}`)
+    expect(rowText('recorded')).toBe(`recordeddsh-better-sidebar/recorded${en.rowPhasePending}pending (waiting for service: authorization)`)
+    expect(document.querySelector('[data-plugin-row="include:wait"]')?.getAttribute('data-state')).toBe('waiting')
+    expect(document.querySelector('[data-plugin-row="include:recorded"]')?.getAttribute('data-state')).toBe('waiting')
     expect(document.querySelector('[data-plugin-row="include:off"]')?.getAttribute('data-state')).toBe('off')
     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.
@@ -239,6 +252,11 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.queryByText(en.partsLabel)).toBeNull()
     expect(screen.getByRole('button', { name: en.addPlugin })).toBeTruthy()
 
+    // The waiting pack's page keeps the wait on its row and repeats no reason above the facts.
+    fireEvent.click(screen.getByRole('button', { name: 'View waiting-pack' }))
+    expect(screen.queryByText(`${en.reasonLabel}: pending (waiting for service: authorization)`)).toBeNull()
+    expect(screen.getByText('1 total · 1 waiting')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
     // 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' }))

Some files were not shown because too many files changed in this diff