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

feat(web): show what an install removed and why the Host refused a change

The install dialog lists the packages the Host removed again after
`pnpm add` with the Host's reason, and every action renders the two
refusals any mutation can meet — another change still running, a session
still running — in the Host's words instead of a generic failure line.
Yichen Jiang 1 месяц назад
Родитель
Сommit
ac517078ca

+ 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;
 # 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:
 # 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
 #   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: b0ccbe9934ac14ce4ee77b26d2a677f36f623be9
-2026-09-04-plugin-management-in-web-settings.zh.md: 40fc1933deb71331c1bc1c2c16baa90afd097e62
+2026-09-04-plugin-management-in-web-settings.md: f7c58cfcb1ad0981fffac4d7c117bdf649e7cbbc
+2026-09-04-plugin-management-in-web-settings.zh.md: 4f0530d5cde92be1021ae348fe4511c8e4b1d62a

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md

@@ -14,7 +14,7 @@ The Host could manage a profile's plugins — install, enable, disable, retry, c
 
 
 **Cards stage per scope, components stay scope-blind.** `ui-settings-plugins` holds one `ScopeSelection` for the configurable tab and a `ScopedCardForms` per card: one `CardForm` per scope, bound lazily, with drafts that belong to the scope they were typed under. The card components read the same hooks and call the same actions, which route to the selected form at call time. A field a named scope does not override reports `inherited` when the global user layer carries it; a reset stages the inherited value. The tab's switch lists the roster's presets plus any scope the document holds a section for. The filesystem skill provider gets its first card, a line-list of extra roots.
 **Cards stage per scope, components stay scope-blind.** `ui-settings-plugins` holds one `ScopeSelection` for the configurable tab and a `ScopedCardForms` per card: one `CardForm` per scope, bound lazily, with drafts that belong to the scope they were typed under. The card components read the same hooks and call the same actions, which route to the selected form at call time. A field a named scope does not override reports `inherited` when the global user layer carries it; a reset stages the inherited value. The tab's switch lists the roster's presets plus any scope the document holds a section for. The filesystem skill provider gets its first card, a line-list of extra roots.
 
 
-**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off.
+**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off. The install dialog also lists the packages the Host removed again after `pnpm add` — a package that is not a dsh package, a bundle whose row id another layer owns — with the Host's reason, and every action renders the two refusals any mutation can meet, `plugins/busy` and `plugins/agents-running`, in the Host's words.
 
 
 **The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
 **The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 
 **卡片按 scope 暂存,组件不知道 scope。** `ui-settings-plugins` 为配置标签页持有一个 `ScopeSelection`,每张卡片一个 `ScopedCardForms`:每个 scope 一个 `CardForm`,惰性绑定,草稿属于输入它时所在的 scope。卡片组件读同样的 hooks、调同样的 actions,后者在调用时路由到选中的表单。具名 scope 未覆盖而全局用户层携带的字段报告 `inherited`;重置暂存的是继承值。标签页的开关列出 roster 的预设,外加文档已有分区的任何 scope。文件系统技能提供方得到它的第一张卡片:额外目录的逐行列表。
 **卡片按 scope 暂存,组件不知道 scope。** `ui-settings-plugins` 为配置标签页持有一个 `ScopeSelection`,每张卡片一个 `ScopedCardForms`:每个 scope 一个 `CardForm`,惰性绑定,草稿属于输入它时所在的 scope。卡片组件读同样的 hooks、调同样的 actions,后者在调用时路由到选中的表单。具名 scope 未覆盖而全局用户层携带的字段报告 `inherited`;重置暂存的是继承值。标签页的开关列出 roster 的预设,外加文档已有分区的任何 scope。文件系统技能提供方得到它的第一张卡片:额外目录的逐行列表。
 
 
-**管理另开一个标签页。** `ui-settings-plugin-manager` 在配置与只读列表之间注册**插件管理**标签页。它从 `plugins` Remote 读包、从 `pluginInventory` 读预设组合,在每次操作与每个 `plugins/changed` 之后重新读取,并把 `plugins/install-log` 流进安装对话框。破坏性操作等待一次已勾选确认,确认框列出宿主报告的依赖方。只读列表补上宿主早已携带的出处事实:哪一层插入了预设行、谁停用了某一行。
+**管理另开一个标签页。** `ui-settings-plugin-manager` 在配置与只读列表之间注册**插件管理**标签页。它从 `plugins` Remote 读包、从 `pluginInventory` 读预设组合,在每次操作与每个 `plugins/changed` 之后重新读取,并把 `plugins/install-log` 流进安装对话框。破坏性操作等待一次已勾选确认,确认框列出宿主报告的依赖方。只读列表补上宿主早已携带的出处事实:哪一层插入了预设行、谁停用了某一行。 安装对话框还会列出宿主在 `pnpm add` 之后又移除掉的包——不是 dsh 包的、行 id 已被别的层占有的组合包——并附宿主的原因;每个操作都会把任何变更都可能遇到的两种拒绝 `plugins/busy` 与 `plugins/agents-running` 按宿主的原话显示出来。
 
 
 **安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('<name>')`。
 **安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('<name>')`。
 
 

+ 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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-manager/README.md
-README.md: 07e7378a6b34538bd24db3e52ee67cd383c2bb29
-README.zh.md: 05c790300315d7c0bc3dd769324a9a82f49999a8
+README.md: 15bf0a5698edfd8f76fccb2608753f688a84eeed
+README.zh.md: 0c6694afa1a7f121db5db1b570b2548937fec5a7

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

@@ -29,7 +29,7 @@ Open the Plugins section in Settings and select the **Manage plugins** tab. The
 
 
 ### Installing a package
 ### Installing a package
 
 
-**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a git URL — and choose whether a newly installed bundle is enabled right away. The dialog streams the run's output and, once pnpm exits, names the dependencies the run added; a non-zero exit keeps the output for reading. The dialog cannot be closed while the run is in flight.
+**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a git URL — and choose whether a newly installed bundle is enabled right away. The dialog streams the run's output and, once pnpm exits, names the dependencies the run added; a non-zero exit keeps the output for reading. The dialog cannot be closed while the run is in flight. When the run finishes, the dialog lists the packages the Host removed again — one that is not a dsh package, or a bundle whose row id another layer owns — each with the Host's reason, under the installed names. A run or any other action the Host refuses because another change is still running, or because a session is running, shows that refusal in the Host's words.
 
 
 ### Switching a bundle
 ### Switching a bundle
 
 

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 
 ### 安装一个包
 ### 安装一个包
 
 
-**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、git 地址——并选择新装的 bundle 是否立即启用。对话框流式显示运行输出,pnpm 退出后列出这次运行新增的依赖;非零退出会保留输出供阅读。运行进行中对话框不能关闭。
+**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、git 地址——并选择新装的 bundle 是否立即启用。对话框流式显示运行输出,pnpm 退出后列出这次运行新增的依赖;非零退出会保留输出供阅读。运行进行中对话框不能关闭。 运行结束后,对话框在已安装的名字下方列出宿主又移除掉的包——不是 dsh 包的,或者行 id 已被别的层占有的组合包——并附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
 
 
 ### 切换一个 bundle
 ### 切换一个 bundle
 
 

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

@@ -426,6 +426,14 @@
   color: var(--dsw-alias-state-error-primary);
   color: var(--dsw-alias-state-error-primary);
 }
 }
 
 
+.removedList {
+  margin: 0;
+  padding-left: 18px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
 .installBody {
 .installBody {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;

+ 22 - 2
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -372,7 +372,18 @@ function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun
             </p>
             </p>
           )
           )
           : null}
           : null}
-        {install.phase === 'failed' ? <p className={css.reason} role="alert">{t('installFailed')}</p> : null}
+        {install.phase === 'done' && install.removed.length > 0
+          ? (
+            <ul className={css.removedList} aria-label={t('installRemovedLabel')}>
+              {install.removed.map(entry => (
+                <li key={entry.name} className={css.reason}>{t('installRemoved', { name: entry.name, reason: entry.reason })}</li>
+              ))}
+            </ul>
+          )
+          : null}
+        {install.phase === 'failed'
+          ? <p className={css.reason} role="alert">{install.failure === null ? t('installFailed') : refusalText(install.failure, t)}</p>
+          : null}
         {install.log === '' && install.phase === 'idle'
         {install.log === '' && install.phase === 'idle'
           ? null
           ? null
           : <pre className={css.log} aria-label={t('installLogLabel')} aria-live="polite">{install.log}</pre>}
           : <pre className={css.log} aria-label={t('installLogLabel')} aria-live="polite">{install.log}</pre>}
@@ -451,12 +462,21 @@ function noticeText(notice: ManagerNotice, t: Translate): string {
         case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
         case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
         case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
         case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
         case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
         case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
-        default: return t('actionFailed', { reason: notice.reason })
+        default: return refusalText(notice, t)
       }
       }
     }
     }
   }
   }
 }
 }
 
 
+/** The copy for a refusal every mutation can meet: the manager is busy, or a session is running. */
+function refusalText(failure: { readonly code: string; readonly reason: string }, t: Translate): string {
+  switch (failure.code) {
+    case 'plugins/busy': return t('busy', { reason: failure.reason })
+    case 'plugins/agents-running': return t('agentsRunning', { reason: failure.reason })
+    default: return failure.code === 'plugins/install-failed' ? t('installFailed') : t('actionFailed', { reason: failure.reason })
+  }
+}
+
 /** Render the plugin manager: packages first, then the selected preset's composition. */
 /** Render the plugin manager: packages first, then the selected preset's composition. */
 export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
 export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
   const { t, presetName, ensure } = props
   const { t, presetName, ensure } = props

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

@@ -85,6 +85,10 @@ export const zh = {
   installDone: '安装完成:{names}',
   installDone: '安装完成:{names}',
   installDoneNothing: '安装完成,没有新增依赖。',
   installDoneNothing: '安装完成,没有新增依赖。',
   installFailed: '安装失败。',
   installFailed: '安装失败。',
+  installRemovedLabel: '已移除的包',
+  installRemoved: '已移除 {name}:{reason}',
+  busy: '另一个改动还在进行,等它结束再试:{reason}',
+  agentsRunning: '有会话正在运行,等它空闲后再改动插件:{reason}',
   installClose: '完成',
   installClose: '完成',
   close: '关闭',
   close: '关闭',
   cancel: '取消',
   cancel: '取消',
@@ -194,6 +198,10 @@ export const en = {
   installDone: 'Installed: {names}',
   installDone: 'Installed: {names}',
   installDoneNothing: 'Install finished with no new dependency.',
   installDoneNothing: 'Install finished with no new dependency.',
   installFailed: 'The install failed.',
   installFailed: 'The install failed.',
+  installRemovedLabel: 'Removed packages',
+  installRemoved: 'Removed {name}: {reason}',
+  busy: 'Another change is still running; try again when it finishes: {reason}',
+  agentsRunning: 'A session is running; change plugins once it is idle: {reason}',
   installClose: 'Done',
   installClose: 'Done',
   close: 'Close',
   close: 'Close',
   cancel: 'Cancel',
   cancel: 'Cancel',

+ 15 - 6
packages/client/ui-settings-plugin-manager/src/client/manager-store.ts

@@ -8,7 +8,12 @@
 
 
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {
 import type {
-  PluginDependents, PluginEnableResult, PluginInstallLogChunk, PluginInventorySnapshot, PluginPackageView,
+  PluginDependents,
+  PluginEnableResult,
+  PluginInstallLogChunk,
+  PluginInstallRejection,
+  PluginInventorySnapshot,
+  PluginPackageView,
   PluginRowTarget,
   PluginRowTarget,
 } from '@deepseek-ai/dsh-api-remotes/client'
 } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -43,8 +48,12 @@ export interface InstallState {
   readonly phase: 'idle' | 'running' | 'done' | 'failed'
   readonly phase: 'idle' | 'running' | 'done' | 'failed'
   /** pnpm's output so far, stdout and stderr interleaved as they arrived. */
   /** pnpm's output so far, stdout and stderr interleaved as they arrived. */
   readonly log: string
   readonly log: string
-  /** Dependencies the last run added, once it finished. */
+  /** Dependencies the last run added and kept, once it finished. */
   readonly installed: readonly string[]
   readonly installed: readonly string[]
+  /** Packages pnpm added that the Host removed again, each with its reason. */
+  readonly removed: readonly PluginInstallRejection[]
+  /** The Host's refusal, when the run failed before or after pnpm. */
+  readonly failure: { readonly code: string; readonly reason: string } | null
 }
 }
 
 
 /** A destructive action waiting for the user's acknowledgement. */
 /** A destructive action waiting for the user's acknowledgement. */
@@ -135,7 +144,7 @@ export function rowKey(target: PluginRowTarget, rowId: string): string {
   return `${target.kind === 'global' ? 'global' : `preset:${target.preset}`}:${rowId}`
   return `${target.kind === 'global' ? 'global' : `preset:${target.preset}`}:${rowId}`
 }
 }
 
 
-const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] }
+const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null }
 
 
 /** Reads and mutates the profile's plugins through the `plugins` and `pluginInventory` Remotes. */
 /** Reads and mutates the profile's plugins through the `plugins` and `pluginInventory` Remotes. */
 export class PluginManagerController {
 export class PluginManagerController {
@@ -335,14 +344,14 @@ export class PluginManagerController {
     const install = this.getSnapshot().install
     const install = this.getSnapshot().install
     const spec = install.spec.trim()
     const spec = install.spec.trim()
     if (install.phase === 'running' || spec === '') return
     if (install.phase === 'running' || spec === '') return
-    this.patchInstall({ phase: 'running', log: '', installed: [] })
+    this.patchInstall({ phase: 'running', log: '', installed: [], removed: [], failure: null })
     // The Host announces `plugins/changed` while the run is still on the
     // The Host announces `plugins/changed` while the run is still on the
     // wire — enabling recomposes before the call answers — and every such
     // wire — enabling recomposes before the call answers — and every such
     // event reads again; those reads must not cancel the run's settlement.
     // event reads again; those reads must not cancel the run's settlement.
     const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable })
     const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable })
     if (this.disposed) return
     if (this.disposed) return
     if (result.ok) {
     if (result.ok) {
-      this.patchInstall({ phase: 'done', installed: result.value.installed })
+      this.patchInstall({ phase: 'done', installed: result.value.installed, removed: result.value.removed })
     } else {
     } else {
       // The Host's reason follows whatever streamed: pnpm's captured log when
       // The Host's reason follows whatever streamed: pnpm's captured log when
       // no chunk arrived, else the refusal that followed a successful pnpm
       // no chunk arrived, else the refusal that followed a successful pnpm
@@ -350,7 +359,7 @@ export class PluginManagerController {
       const current = this.getSnapshot().install.log
       const current = this.getSnapshot().install.log
       const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
       const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
       const log = current === '' || current.endsWith(reason) ? (current === '' ? reason : current) : `${current}\n${reason}`
       const log = current === '' || current.endsWith(reason) ? (current === '' ? reason : current) : `${current}\n${reason}`
-      this.patchInstall({ phase: 'failed', log })
+      this.patchInstall({ phase: 'failed', log, failure: { code: result.error.code, reason } })
     }
     }
     void this.load()
     void this.load()
   }
   }

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

@@ -47,7 +47,7 @@ const READY: PluginManagerState = {
   selectedPreset: null,
   selectedPreset: null,
   busy: [],
   busy: [],
   notice: null,
   notice: null,
-  install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] },
+  install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null },
   confirm: null,
   confirm: null,
 }
 }
 
 
@@ -302,7 +302,7 @@ describe('PluginManagerSettingsTab', () => {
   })
   })
 
 
   it('drives the install dialog through its phases', () => {
   it('drives the install dialog through its phases', () => {
-    const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+    const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
     const dialog = screen.getByRole('dialog', { name: en.installTitle })
     const dialog = screen.getByRole('dialog', { name: en.installTitle })
     expect(dialog).toBeTruthy()
     expect(dialog).toBeTruthy()
     const spec = screen.getByLabelText(en.installSpecLabel) as HTMLInputElement
     const spec = screen.getByLabelText(en.installSpecLabel) as HTMLInputElement
@@ -313,33 +313,39 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
     expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
     expect(screen.queryByLabelText(en.installLogLabel)).toBeNull()
     expect(screen.queryByLabelText(en.installLogLabel)).toBeNull()
 
 
-    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [] } })
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
     fireEvent.click(screen.getByRole('button', { name: en.installRun }))
     fireEvent.click(screen.getByRole('button', { name: en.installRun }))
     expect(actions.runInstall).toHaveBeenCalledTimes(1)
     expect(actions.runInstall).toHaveBeenCalledTimes(1)
     fireEvent.click(screen.getByRole('button', { name: en.cancel }))
     fireEvent.click(screen.getByRole('button', { name: en.cancel }))
     expect(actions.closeInstall).toHaveBeenCalledTimes(1)
     expect(actions.closeInstall).toHaveBeenCalledTimes(1)
 
 
-    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [] } })
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [], removed: [], failure: null } })
     expect(screen.getByRole('button', { name: en.installRunning })).toHaveProperty('disabled', true)
     expect(screen.getByRole('button', { name: en.installRunning })).toHaveProperty('disabled', true)
     expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('Progress')
     expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('Progress')
     expect(screen.getByLabelText(en.installSpecLabel)).toHaveProperty('disabled', true)
     expect(screen.getByLabelText(en.installSpecLabel)).toHaveProperty('disabled', true)
 
 
-    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'] } })
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'], removed: [], failure: null } })
     expect(screen.getByRole('status').textContent).toBe('Installed: pkg')
     expect(screen.getByRole('status').textContent).toBe('Installed: pkg')
-    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [] } })
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [], removed: [], failure: null } })
     expect(screen.getByRole('status').textContent).toBe(en.installDoneNothing)
     expect(screen.getByRole('status').textContent).toBe(en.installDoneNothing)
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [], removed: [{ name: 'lib', reason: 'declares neither a dsh bundle nor a plugin module' }], failure: null } })
+    expect(screen.getByLabelText(en.installRemovedLabel).textContent).toBe('Removed lib: declares neither a dsh bundle nor a plugin module')
     fireEvent.click(screen.getByRole('button', { name: en.installClose }))
     fireEvent.click(screen.getByRole('button', { name: en.installClose }))
     expect(actions.closeInstall).toHaveBeenCalledTimes(2)
     expect(actions.closeInstall).toHaveBeenCalledTimes(2)
 
 
-    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [] } })
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: null } })
     expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
     expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: { code: 'plugins/busy', reason: 'add x is still running' } } })
+    expect(screen.getByRole('alert').textContent).toBe('Another change is still running; try again when it finishes: add x is still running')
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: { code: 'plugins/agents-running', reason: '1 running' } } })
+    expect(screen.getByRole('alert').textContent).toBe('A session is running; change plugins once it is idle: 1 running')
     expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('ERR')
     expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('ERR')
     fireEvent.click(screen.getByRole('button', { name: en.close }))
     fireEvent.click(screen.getByRole('button', { name: en.close }))
     expect(actions.closeInstall).toHaveBeenCalledTimes(3)
     expect(actions.closeInstall).toHaveBeenCalledTimes(3)
     fireEvent.keyDown(document, { key: 'Escape' })
     fireEvent.keyDown(document, { key: 'Escape' })
     expect(actions.closeInstall).toHaveBeenCalledTimes(4)
     expect(actions.closeInstall).toHaveBeenCalledTimes(4)
 
 
-    set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+    set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
     expect(screen.queryByRole('dialog')).toBeNull()
     expect(screen.queryByRole('dialog')).toBeNull()
   })
   })
 
 

+ 14 - 5
packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts

@@ -32,7 +32,14 @@ const STANDARD: PresetGroup = {
 }
 }
 
 
 /** What one install run answers. */
 /** What one install run answers. */
-type InstallValue = { installed: string[]; enabled: string[]; installedOnly: string[]; plain: string[]; jobId: string }
+type InstallValue = {
+  installed: string[]
+  removed: { name: string; reason: string }[]
+  enabled: string[]
+  installedOnly: string[]
+  plain: string[]
+  jobId: string
+}
 
 
 function ok<T>(value: T) {
 function ok<T>(value: T) {
   return { ok: true as const, value }
   return { ok: true as const, value }
@@ -52,7 +59,7 @@ function deferred<T>() {
 function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
 function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
   const plugins = {
   const plugins = {
     list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
     list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
-    add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))),
+    add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))),
     uninstall: vi.fn(() => Promise.resolve(ok(undefined))),
     uninstall: vi.fn(() => Promise.resolve(ok(undefined))),
     enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
     enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
     disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))),
     disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))),
@@ -273,9 +280,10 @@ describe('PluginManagerController', () => {
     controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' })
     controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' })
     controller.appendLog({ jobId: 'j2', spec: 'other', stream: 'stdout', text: 'not mine' })
     controller.appendLog({ jobId: 'j2', spec: 'other', stream: 'stdout', text: 'not mine' })
     expect(state().install.log).toBe('Progress\n')
     expect(state().install.log).toBe('Progress\n')
-    gate.resolve(ok({ installed: ['dsh-better-sidebar'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))
+    gate.resolve(ok({ installed: ['dsh-better-sidebar'], removed: [{ name: 'lib', reason: 'not a plugin' }], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     expect(state().install.installed).toEqual(['dsh-better-sidebar'])
     expect(state().install.installed).toEqual(['dsh-better-sidebar'])
+    expect(state().install.removed).toEqual([{ name: 'lib', reason: 'not a plugin' }])
     controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 })
     controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 })
     expect(state().install.log).toBe('Progress\n')
     expect(state().install.log).toBe('Progress\n')
     await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
     await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
@@ -296,7 +304,7 @@ describe('PluginManagerController', () => {
     face.runInstall()
     face.runInstall()
     // The Host announces the change before the run answers; the read it triggers must not drop the answer.
     // The Host announces the change before the run answers; the read it triggers must not drop the answer.
     await controller.load()
     await controller.load()
-    addGate.resolve(ok({ installed: ['pkg'], enabled: ['pkg'], installedOnly: [], plain: [], jobId: 'j' }))
+    addGate.resolve(ok({ installed: ['pkg'], removed: [], enabled: ['pkg'], installedOnly: [], plain: [], jobId: 'j' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
 
 
     face.uninstall(BUNDLE.name)
     face.uninstall(BUNDLE.name)
@@ -320,6 +328,7 @@ describe('PluginManagerController', () => {
     face.runInstall()
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.log).toBe('ERR_PNPM')
     expect(state().install.log).toBe('ERR_PNPM')
+    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'ERR_PNPM' })
     face.runInstall()
     face.runInstall()
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(2) })
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(2) })
     await vi.waitFor(() => { expect(state().install.log).toBe('offline') })
     await vi.waitFor(() => { expect(state().install.log).toBe('offline') })
@@ -352,7 +361,7 @@ describe('PluginManagerController', () => {
     const before = state()
     const before = state()
     controller.dispose()
     controller.dispose()
     enableGate.resolve(ok({ changed: true, effect: 'live' }))
     enableGate.resolve(ok({ changed: true, effect: 'live' }))
-    installGate.resolve(ok({ installed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
+    installGate.resolve(ok({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()