diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index ad44971a01..a69be350ba 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 005ae2cf873c0a2e - zh: bc7ca9472105a6a0 + en: c0ec2417ce428285 + zh: 257122d7920d49c5 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 9f2236ff7b..c953bde19d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching a bundle on or off does not change its group or position. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index a2d96ba2f2..79748156ed 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 12e877ece3..26fab6011b 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -103,13 +103,14 @@ const BUILTIN_PROFILE_BUNDLES = new Set([ '@deepseek-ai/dsh-sdk-minimal', ]) -/** Display order on the main and full optional plugin lists; new bundles join the main list. */ +/** The optional bundles the main list always holds, in this order; new bundles join the main list. */ const EXTENSION_BUNDLES = [ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', '@deepseek-ai/dsh-experimental-auto-review', '@deepseek-ai/dsh-experimental-inspector-profile', ] +/** The optional bundles the main list holds back until the profile picks them; the More list shows them from the start. */ const MORE_BUNDLES = [ '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-cot-translation-bundle', @@ -121,13 +122,36 @@ const MORE_BUNDLES = [ '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code', ] +/** + * Whether the main list shows an official optional bundle: the always-held ones, and any held-back one the profile enabled. + * @param pkg - the official bundle as the Host reports it. + * @returns whether the main list shows it. + */ +function pinnedToMainList(pkg: PackageView): boolean { + return !MORE_BUNDLES.includes(pkg.name) || pkg.enabled +} -function orderPackages(packages: readonly PackageView[], order: readonly string[]): PackageView[] { - const rank = (name: string): number => { - const index = order.indexOf(name) - return index < 0 ? order.length : index - } - return [...packages].sort((a, b) => rank(a.name) - rank(b.name)) +/** + * Where an optional bundle sits on both lists: the always-held ones in their order, then the bundles neither list + * ranks, then the held-back ones in theirs, so Codex and Claude Code stay last on the More list. + * @param name - the bundle's package name. + * @returns the rank; bundles of equal rank keep the order the Host reported. + */ +function optionalBundleRank(name: string): number { + const alwaysHeld = EXTENSION_BUNDLES.indexOf(name) + if (alwaysHeld >= 0) return alwaysHeld + const heldBack = MORE_BUNDLES.indexOf(name) + return heldBack < 0 ? EXTENSION_BUNDLES.length : EXTENSION_BUNDLES.length + 1 + heldBack +} + +/** + * Order the official optional bundles as both lists show them, so a bundle the profile picks from the More list + * keeps its place among the neighbours the main list already holds. + * @param packages - the official bundles the Host offers. + * @returns the bundles in display order. + */ +function orderOptionalBundles(packages: readonly PackageView[]): PackageView[] { + return [...packages].sort((a, b) => optionalBundleRank(a.name) - optionalBundleRank(b.name)) } /** How long a toast holds: long enough to read a failure that names what broke. */ @@ -1422,8 +1446,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { && (pkg.installed || pkg.official || pkg.error !== undefined)) const mine = listed.filter(pkg => !pkg.official) const official = listed.filter(pkg => pkg.official) - const extensions = orderPackages(official.filter(pkg => !MORE_BUNDLES.includes(pkg.name)), EXTENSION_BUNDLES) - const more = [...extensions, ...orderPackages(official.filter(pkg => MORE_BUNDLES.includes(pkg.name)), MORE_BUNDLES)] + // Both lists read one order over every offered bundle: the main list holds the always-held ones plus the held-back + // ones the profile enabled, and the More list holds all of them, so enabling a bundle from the More list pins it + // to the main list without moving it among its neighbours there. + const ordered = orderOptionalBundles(official) + const pinned = new Set(official.filter(pinnedToMainList).map(pkg => pkg.name)) + const extensions = ordered.filter(pkg => pinned.has(pkg.name)) + const more = ordered const loaded = state.status === 'ready' || state.status === 'error' const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined @@ -1431,7 +1460,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined const detailFromMore = (view.kind === 'package' || view.kind === 'row') - && (view.from === 'more' || MORE_BUNDLES.includes(view.name)) + && (view.from === 'more' || (MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) const showsMoreList = showsCards && (view.kind === 'more' || detailFromMore) const showsMainList = showsCards && !showsMoreList const openPkgInMore = openPkg !== undefined && openPkg.official && detailFromMore 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 add1b8b8b2..98e7efa54e 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -865,11 +865,11 @@ describe('PluginManagerPage', () => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' renderTab( { packages: [ - pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true }), + pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), - pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true }), + pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), pkg({ name: '@acme/new-official', official: true }), pkg(), ] }, @@ -919,7 +919,7 @@ describe('PluginManagerPage', () => { it('returns from package details to the secondary page and retains it across React remounts', () => { const name = '@deepseek-ai/dsh-experimental-terminal-bundle' - const b = renderTab({ packages: [pkg({ name, official: true })] }) + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) @@ -933,19 +933,44 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() }) - it('keeps toggled plugins in their group and position', () => { - const names = ['@deepseek-ai/dsh-experimental-cot-translation-bundle', '@deepseek-ai/dsh-experimental-terminal-bundle'] as const - const packages = names.map(name => pkg({ name, official: true, enabled: false })) - const b = renderTab({ packages }) + it('pins a bundle the profile picks onto the main list, in its full-list place', () => { + const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const first = '@deepseek-ai/dsh-experimental-tool-worktree' + const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' + const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) + const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + const cards = (group: 'extensions' | 'more'): (string | null)[] => { + const list = document.querySelector(`[data-plugin-group="${group}"]`) as HTMLElement + return within(list).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) + } + expect(cards('extensions')).toEqual([team, second]) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) - const group = document.querySelector('[data-plugin-group="more"]') as HTMLElement - const cards = () => within(group).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) - expect(cards()).toEqual(names) - fireEvent.click(within(group).getByRole('switch', { name: en.enableToggle.replace('{name}', names[1]) })) - expect(b.actions.setEnabled).toHaveBeenCalledWith(names[1], true) - b.set({ packages: packages.map((entry, index) => ({ ...entry, enabled: index === 1 })) }) - expect(cards()).toEqual(names) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, true) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, true)] }) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(cards('extensions')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, false) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + expect(cards('extensions')).toEqual([team, second]) + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(cards('more')).toEqual([team, first, second]) + }) + + it('returns a held-back bundle opened outside the lists to the list that shows it', () => { + const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + expect(document.querySelector(`[data-plugin-detail="${name}"]`)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() + b.set({ packages: [pkg({ name, official: true, enabled: true })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() }) it('starts on the main list when the navigation store is recreated', () => { @@ -1914,8 +1939,8 @@ describe('PluginManagerPage', () => { Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) try { const { actions, set } = renderTab({ packages: [ - pkg({ name, official: true }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), + pkg({ name, official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), ] }) expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() set({ highlight: name }) @@ -1932,7 +1957,7 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() expect(scrollIntoView).toHaveBeenCalledTimes(1) - set({ packages: [pkg({ name, official: true })] }) + set({ packages: [pkg({ name, official: true, enabled: false })] }) act(() => { vi.advanceTimersByTime(799) }) expect(actions.clearHighlight).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) })