diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index d1e00c2cd5..511e290621 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -9,8 +9,8 @@ en: edefff7cd986a509 zh: a270839fa86976a2 /agent-note-guided-plugin-installation/decision: - en: 30f1e636973df06a - zh: 0f874b88a5d4d7f5 + en: 5448b9f8aaa9a569 + zh: 9024c5328c5da3bd /agent-note-guided-plugin-installation/alternatives-considered: en: be64b541f4d81e7c zh: 8600531c08ab4288 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index c0e9e68325..40cbf6179b 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for. -**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed. +**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed. **Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 0d914869a3..c53c138059 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -20,7 +20,7 @@ Status: implemented **取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。 -**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 +**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 **当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 7280e0bd96..c826f9a9b5 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1551,8 +1551,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { // A contributed tab that left the ledger falls back to Installed, and stays there when it returns. const tab = tabs.some(item => item.id === selectedTab) ? selectedTab : INSTALLED_TAB useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab]) - // A bundle an install just enabled is shown where its card is. - useEffect(() => { if (state.highlight !== null) setTab(INSTALLED_TAB) }, [state.highlight, setTab]) const tabsId = useId() const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) @@ -1815,7 +1813,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} - {showsCards && activated !== undefined && !state.install.open + {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} + {installedCards && activated !== undefined && !state.install.open ? renderSlot('plugins.bundle.activation', { packageName: activated.name, onDismiss: () => { setActivation(null) }, diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 469e565656..2e9d2103dd 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -216,7 +216,7 @@ function renderTab( describe('PluginManagerPage', () => { it('shows no tabs without contributions, and switches between Installed and a contributed tab', () => { - const { actions, navigation, setTabs, set } = renderTab({ packages: [pkg()] }, {}, { + const { actions, navigation, setTabs } = renderTab({ packages: [pkg()] }, {}, { 'plugins.tab:discover': (_view, owner) => { const tab = owner as PluginTabProps return ( @@ -247,13 +247,20 @@ describe('PluginManagerPage', () => { setTabs([]) expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull() expect(navigation.getSnapshot().tab).toBe('installed') - // Enabling a bundle an install added highlights its card, so the page shows Installed. + // A contributed tab that returns can be selected again. setTabs([{ id: 'discover', label: 'Discover' }]) fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() + }) + + it('marks the bundle an install enabled without leaving a contributed tab', () => { + const { navigation, set } = renderTab({ packages: [pkg()] }, {}, {}, {}, [{ id: 'discover', label: 'Discover' }]) + fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) + expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() + // Enable now publishes the new bundle's mark; the mark scrolls to a visible card and selects no page. set({ highlight: 'dsh-better-sidebar' }) - expect(navigation.getSnapshot().tab).toBe('installed') - expect(screen.getByRole('tabpanel', { name: en.installedTab })).toBeTruthy() + expect(navigation.getSnapshot().tab).toBe('discover') + expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() }) it('keeps the Installed tab panel through loading and the More list out of the tabs', () => { @@ -2318,6 +2325,24 @@ it('dismisses activation guidance until the user enables the bundle again', () = expect(screen.getByText('Later')).toBeTruthy() }) +it('keeps activation guidance with the Installed list when a contributed tab enables the bundle', () => { + const name = 'dsh-better-sidebar' + const subject = { spec: `${name}@0.16.0`, status: 'accepted', kind: 'registry', name, bundle: true, registry: null } as const + const { set } = renderTab({ + install: { ...IDLE_INSTALL, open: true, phase: 'done', subject, installed: name }, + }, { bundles: new Set([name]) }, { + [`plugins.bundle.activation:${name}`]: () =>
Enable guidance
, + }, {}, [{ id: 'discover', label: 'Discover' }]) + fireEvent.click(screen.getByRole('button', { name: en.installEnableNow })) + set({ install: IDLE_INSTALL, packages: [pkg()] }) + expect(screen.getByText('Enable guidance')).toBeTruthy() + // A contributed tab shows its own content, not guidance for the Installed list. + fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) + expect(screen.queryByText('Enable guidance')).toBeNull() + fireEvent.click(screen.getByRole('tab', { name: en.installedTab })) + expect(screen.getByText('Enable guidance')).toBeTruthy() +}) + it('supplies the accepted entry values and atomic mutation action to a custom plugin page', () => { const mutate = vi.fn(async () => true) const form: ConfigPageForm = {