diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index 3b7a533f1a..d062b53002 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -25,5 +25,5 @@
- text: "1"
- list:
- listitem:
- - button "查看 live-client": live-client
- - switch "启用 live-client"
+ - button "查看 @fixture/live-client": "@fixture/live-client"
+ - switch "启用 @fixture/live-client"
diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md
index 85b1d52b9e..03308874b2 100644
--- a/apps/web/tests/expected/plugin-config/row.expected.md
+++ b/apps/web/tests/expected/plugin-config/row.expected.md
@@ -1,5 +1,7 @@
-- button "返回 live-client": live-client
-- heading "fixture-live-client" [level=3]
+- button "返回 @fixture/live-client": "@fixture/live-client"
+- heading "@fixture/live-client" [level=3]
+- paragraph:
+ - code: fixture-live-client
- paragraph:
- code: "@fixture/live-client"
- paragraph: 示例配置项
diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
new file mode 100644
index 0000000000..74cc1b4c15
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
@@ -0,0 +1,24 @@
+- button "Back to plugins": Plugins
+- button "Uninstall @fixture/bundle": Uninstall
+- switch "Enable @fixture/bundle"
+- heading "@fixture/bundle" [level=3]
+- text: v0.0.1
+- paragraph:
+ - code: "@fixture/bundle"
+- paragraph: Registry description for the fixture bundle.
+- heading "Components" [level=4]
+- text: 3 total · 3 off
+- list:
+ - listitem:
+ - text: "@fixture/bundle Registry description for the fixture bundle."
+ - code: fixture-row
+ - text: "Off"
+ - listitem:
+ - text: File Search Search package introduction.
+ - code: fixture-search
+ - code: "@fixture/bundle/search"
+ - text: "Off"
+ - listitem:
+ - text: "@fixture/bundle/review"
+ - code: fixture-review
+ - text: "Off"
diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md
new file mode 100644
index 0000000000..0512893d3e
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/exports.expected.md
@@ -0,0 +1,25 @@
+- button "返回插件列表": 插件列表
+- button "卸载 @fixture/bundle": 卸载
+- switch "启用 @fixture/bundle"
+- heading "@fixture/bundle" [level=3]
+- text: v0.0.1
+- paragraph:
+ - code: "@fixture/bundle"
+- paragraph: Registry description for the fixture bundle.
+- heading "包含的组件" [level=4]
+- text: 共 3 个 · 3 已停用
+- list:
+ - listitem:
+ - text: "@fixture/bundle Registry description for the fixture bundle."
+ - code: fixture-row
+ - text: 已关闭
+ - listitem:
+ - text: 文件搜索 搜索工作区中的文件。
+ - code: fixture-search
+ - code: "@fixture/bundle/search"
+ - text: 已关闭
+ - listitem:
+ - text: 代码审查 审查工作区中的改动。
+ - code: fixture-review
+ - code: "@fixture/bundle/review"
+ - text: 已关闭
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index ae850f5daa..92fafe32cb 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -25,6 +25,6 @@
- text: "1"
- list:
- listitem:
- - button "查看 bundle": bundle
- - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
- - switch "启用 bundle" [checked]
+ - button "查看 @fixture/bundle": "@fixture/bundle"
+ - text: Registry description for the fixture bundle.
+ - switch "启用 @fixture/bundle" [checked]
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index a13d1a001e..45658f20cf 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -25,6 +25,6 @@
- text: "1"
- list:
- listitem:
- - button "查看 bundle": bundle
- - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
- - switch "启用 bundle"
+ - button "查看 @fixture/bundle": "@fixture/bundle"
+ - text: Registry description for the fixture bundle.
+ - switch "启用 @fixture/bundle"
diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
index 5eec14673b..93ebcf1143 100644
--- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
+++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
@@ -1,10 +1,9 @@
- button "返回插件列表": 插件列表
-- switch "启用 missing-bundle" [checked]
-- heading "missing-bundle" [level=3]
+- switch "启用 @fixture/missing-bundle" [checked]
+- heading "@fixture/missing-bundle" [level=3]
- text: 异常
- paragraph:
- code: "@fixture/missing-bundle"
-- paragraph: 暂无描述。
- status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed"
- heading "包含的组件" [level=4]
- paragraph: 这个插件包不包含任何组件。
diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
index 4c654a7971..612291fe84 100644
--- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
@@ -2,7 +2,7 @@
- listitem:
- button "tool-subagent-control, tool-subagent-control, 已启用":
- strong: tool-subagent-control
- - text: 已启用
+ - text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- code: tool-subagent-control
- listitem:
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
@@ -12,20 +12,20 @@
- listitem:
- button "tool-subagent, tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent
- listitem:
- button "tool-subagent, tool-subagent-fork, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-fork
- listitem:
- button "tool-subagent, tool-subagent-codex, 已停用":
- strong: tool-subagent
- - text: 已停用
+ - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-codex
- listitem:
- button "tool-subagent, tool-subagent-claude-code, 已停用":
- strong: tool-subagent
- - text: 已停用
+ - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-claude-code
diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md
index e8536c690a..7cfe33f5f0 100644
--- a/apps/web/tests/expected/settings-chrome/plugins.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md
@@ -1,5 +1,5 @@
- listitem:
- button "tool-subagent, tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
index a3e71146b9..8038d9e378 100644
--- a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
@@ -1,4 +1,8 @@
-# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes.
+# Web e2e fixture bundle with independently localized plugin exports.
- insert:
- id: fixture-row
name: '@fixture/bundle'
+ - id: fixture-search
+ name: '@fixture/bundle/search'
+ - id: fixture-review
+ name: '@fixture/bundle/review'
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json
new file mode 100644
index 0000000000..6da7422b1a
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json
@@ -0,0 +1,3 @@
+{
+ "meta": {}
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json
new file mode 100644
index 0000000000..90a5b56fb2
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "代码审查",
+ "description": "审查工作区中的改动。"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json
new file mode 100644
index 0000000000..e27e893200
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "File Search"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json
new file mode 100644
index 0000000000..44f2e03849
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "文件搜索",
+ "description": "搜索工作区中的文件。"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
index 263634fb08..3224dfa296 100644
--- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
@@ -2,9 +2,18 @@
"name": "@fixture/bundle",
"version": "0.0.1",
"private": true,
- "description": "Web e2e fixture: a bundle whose one row is an inert plugin.",
+ "description": "Registry description for the fixture bundle.",
"type": "module",
"main": "index.js",
+ "exports": {
+ ".": "./index.js",
+ "./package.json": "./package.json",
+ "./search": "./search.js",
+ "./search/package.json": "./search.package.json",
+ "./review": "./review.js",
+ "./search/locale/*.json": "./locale/search/*.json",
+ "./review/locale/*.json": "./locale/review/*.json"
+ },
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js
new file mode 100644
index 0000000000..356cb5cf9a
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js
@@ -0,0 +1,2 @@
+/** Inert review plugin for exported metadata browser coverage. */
+export function apply() {}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js
new file mode 100644
index 0000000000..c8858a7889
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js
@@ -0,0 +1,2 @@
+/** Inert search plugin for exported metadata browser coverage. */
+export function apply() {}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json
new file mode 100644
index 0000000000..52a0c0f420
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json
@@ -0,0 +1,4 @@
+{
+ "name": "Search plugin",
+ "description": "Search package introduction."
+}
diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts
index 825d95b081..11394b867b 100644
--- a/apps/web/tests/plugin-config.e2e.ts
+++ b/apps/web/tests/plugin-config.e2e.ts
@@ -33,6 +33,7 @@ describe('web e2e: plugin configuration pages', () => {
// The live-client fixture is a bundle with a browser half; switched on
// below, that half registers its row's configuration into the page.
scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
})
browser = await chromium.launch()
@@ -286,21 +287,21 @@ describe('web e2e: plugin configuration pages', () => {
const panel = await openPlugins()
// Off, the bundle's browser half is not loaded and the row has no configuration to open.
- await panel.getByRole('button', { name: '查看 live-client', exact: true }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/live-client', exact: true }).click()
const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' })
await row.waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0)
+ expect(await panel.getByRole('button', { name: '配置 @fixture/live-client' }).count()).toBe(0)
// Switched on, the Host recomposes and the browser half mounts without a
// reload; its registration puts the configure control on the row.
- await panel.getByRole('switch', { name: '启用 live-client' }).click()
- const configure = panel.getByRole('button', { name: '配置 fixture-live-client' })
+ await panel.getByRole('switch', { name: '启用 @fixture/live-client' }).click()
+ const configure = panel.getByRole('button', { name: '配置 @fixture/live-client' })
await configure.waitFor({ timeout: 30_000 })
await configure.click()
const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]')
await rowPage.waitFor({ timeout: 10_000 })
- expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client')
+ expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('@fixture/live-client')
expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1)
const form = rowPage.getByRole('form', { name: '动态插件配置' })
await form.getByLabel('问候语').fill('你好')
@@ -309,7 +310,7 @@ describe('web e2e: plugin configuration pages', () => {
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE)
- await rowPage.getByRole('button', { name: '返回 live-client' }).click()
+ await rowPage.getByRole('button', { name: '返回 @fixture/live-client' }).click()
await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index f2146bc906..373b0ba459 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -19,6 +19,8 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
+const EXPORTS_EXPECTED = join(SNAPSHOT_DIR, 'exports.expected.md')
+const EXPORTS_EN_EXPECTED = join(SNAPSHOT_DIR, 'exports-en.expected.md')
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
const MODE = webSnapshotMode()
@@ -30,6 +32,7 @@ describe('web e2e: plugin manager', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] },
})
browser = await chromium.launch()
@@ -52,11 +55,32 @@ describe('web e2e: plugin manager', () => {
}
}
+ /** Change the UI language through Settings and close the dialog. */
+ async function setLanguage(language: 'en' | 'zh'): Promise {
+ if (await page.locator('html').getAttribute('lang') === language) return
+ const settings = language === 'en' ? '设置' : 'Settings'
+ const source = language === 'en' ? '中文' : 'English'
+ const target = language === 'en' ? 'English' : '中文'
+ if (await page.getByRole('dialog', { name: settings }).count() === 0) {
+ await page.getByRole('button', { name: settings, exact: true }).click()
+ }
+ await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click()
+ await page.getByRole('menuitem', { name: target }).click()
+ const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Settings' : '设置' })
+ await dialog.waitFor()
+ await page.keyboard.press('Escape')
+ await expect.poll(() => dialog.count()).toBe(0)
+ }
+
/** Select the sidebar's Plugins entry and wait for the management page in the main column. */
async function openPluginsPanel() {
await closeSettings()
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
+ await panel.waitFor({ timeout: 10_000 })
+ while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
+ await panel.getByRole('button', { name: /^返回/ }).first().click()
+ }
await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
return panel
}
@@ -69,8 +93,8 @@ describe('web e2e: plugin manager', () => {
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
- await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click()
- const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true })
+ await panel.getByRole('button', { name: '查看 @fixture/missing-bundle', exact: true }).click()
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/missing-bundle', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await toggle.isDisabled()).toBe(false)
await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 })
@@ -93,8 +117,8 @@ describe('web e2e: plugin manager', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
const panel = await openPluginsPanel()
- await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 })
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
// The profile's own group holds its one bundle; the installation's optional bundles open the Official
// group, followed by the official plugins that registered their configuration, and its other bundles
@@ -104,18 +128,47 @@ describe('web e2e: plugin manager', () => {
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1)
// A bundle that is off still shows the rows its patch declares, without switches.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
- await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 })
+ expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0)
+ await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 })
await panel.getByRole('button', { name: '返回插件列表' }).click()
- await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0)
+ await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0)
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+ it('localizes independent exports and falls back per field without activating the plugins', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports'))
+ const panel = await openPluginsPanel()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
+ const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' })
+ const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' })
+ await search.getByText('文件搜索', { exact: true }).waitFor()
+ await review.getByText('代码审查', { exact: true }).waitFor()
+ expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
+ expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
+ await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
+ expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
+ await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
+ try {
+ await setLanguage('en')
+ await search.getByText('File Search', { exact: true }).waitFor()
+ expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
+ expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1)
+ expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
+ await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
+ expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle')
+ } finally {
+ await setLanguage('zh')
+ await closeSettings()
+ }
+ await panel.getByRole('button', { name: '返回插件列表' }).click()
+ expect(tripwire.pageErrors).toEqual([])
+ })
+
it('updates built-in names and descriptions when the UI language changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
const panel = await openPluginsPanel()
@@ -123,15 +176,16 @@ describe('web e2e: plugin manager', () => {
const packageName = panel.locator('[data-plugin-name]')
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1)
+ const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' })
+ await child.getByText('团队工具', { exact: true }).waitFor()
+ expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1)
try {
- await page.getByRole('button', { name: '设置', exact: true }).click()
- await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
- await page.getByRole('menuitem', { name: 'English' }).click()
- await page.getByRole('dialog', { name: 'Settings' }).waitFor()
- await page.keyboard.press('Escape')
+ await setLanguage('en')
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
+ await child.getByText('Team Tools', { exact: true }).waitFor()
+ expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
@@ -140,14 +194,7 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
}
} finally {
- if (await page.locator('html').getAttribute('lang') === 'en') {
- if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
- await page.getByRole('button', { name: 'Settings', exact: true }).click()
- }
- await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
- await page.getByRole('menuitem', { name: '中文' }).click()
- await page.getByRole('dialog', { name: '设置' }).waitFor()
- }
+ await setLanguage('zh')
await closeSettings()
}
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor()
@@ -244,7 +291,7 @@ describe('web e2e: plugin manager', () => {
it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
const panel = await openPluginsPanel()
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
await toggle.waitFor({ timeout: 20_000 })
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
expect(mounted()?.fiber?.state).toBeUndefined()
@@ -262,8 +309,8 @@ describe('web e2e: plugin manager', () => {
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
// The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
- const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' })
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
+ const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' })
await rowSwitch.waitFor({ timeout: 10_000 })
expect(await rowSwitch.getAttribute('aria-checked')).toBe('true')
await rowSwitch.click()
@@ -283,7 +330,9 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
- await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md',
+ ])
})
})
@@ -291,6 +340,7 @@ describe('web e2e: plugin manager', () => {
describe('web e2e: startup-applied plugin management', () => {
it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => {
const scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
})
let browser: Browser | undefined
@@ -302,7 +352,7 @@ describe('web e2e: startup-applied plugin management', () => {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
await toggle.waitFor({ timeout: 20_000 })
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
const bundles = async () => {
@@ -317,9 +367,9 @@ describe('web e2e: startup-applied plugin management', () => {
await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 })
expect(mounted()?.fiber?.state).toBeUndefined()
// The pack's page lists its rows from their declarations, with no live entry to switch.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true)
+ expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true)
await panel.getByRole('button', { name: '返回插件列表' }).click()
await toggle.click()
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index b396d5f969..d84614d33c 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => {
let tripwire: ReturnType
beforeAll(async () => {
- scaffold = await launchWebScaffold({ developerTools: false })
+ scaffold = await launchWebScaffold({
+ developerTools: false,
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+ })
browser = await chromium.launch()
// Chinese browser: the shared page asserts the localized settings surface
// the client derives from it (the English default has its own spec below).
diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json
index b75b622d52..7a40d48b79 100644
--- a/packages/client/locale/package.json
+++ b/packages/client/locale/package.json
@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-connection": "workspace:^",
diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts
index 8370696886..cd1ef5cac1 100644
--- a/packages/client/locale/src/client/index.ts
+++ b/packages/client/locale/src/client/index.ts
@@ -5,6 +5,7 @@
* its own settings surface.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -200,6 +201,20 @@ export class LocaleRuntime {
return this.snapshot
}
+ /**
+ * Resolve package text through the active language's declared fallback chain.
+ * Plain strings stay verbatim; maps do not consult registered dictionaries.
+ * @param text - package text whose locale keys are lowercase and include English.
+ * @returns the first available translation, including an empty string.
+ */
+ resolveText(text: LocalizedText): string {
+ if (typeof text === 'string') return text
+ return this.fallbackChain(this.snapshot.active).reduceRight(
+ (resolved, locale) => text[localeKey(locale)] ?? resolved,
+ text.en,
+ )
+ }
+
/**
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
* reference between changes, uSES-safe).
diff --git a/packages/client/locale/tests/localized-text.client.spec.ts b/packages/client/locale/tests/localized-text.client.spec.ts
new file mode 100644
index 0000000000..a567962ae7
--- /dev/null
+++ b/packages/client/locale/tests/localized-text.client.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { LocaleRuntime } from '../src/client/index.ts'
+
+function make() {
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
+ return { ctx, locale: new LocaleRuntime(ctx) }
+}
+
+describe('package text resolution', () => {
+ it('keeps strings verbatim without common lookup or interpolation', () => {
+ const { ctx, locale } = make()
+ ctx.effect(() => locale.register('common', 'zh', { retry: '重试' }), 'test: common dictionary')
+ locale.setLocale('zh')
+
+ expect(locale.resolveText('retry')).toBe('retry')
+ expect(locale.resolveText('Hello, {name}')).toBe('Hello, {name}')
+ expect(locale.resolveText('')).toBe('')
+ expect(locale.resolveText({ en: 'retry' })).toBe('retry')
+ })
+
+ it('resolves each field in the current locale without registering map languages', () => {
+ const { locale } = make()
+ const title = { en: 'Tools', zh: '工具', ja: 'ツール' }
+ locale.setLocale('zh')
+ const snapshot = locale.getSnapshot()
+
+ expect(locale.resolveText(title)).toBe('工具')
+ expect(locale.resolveText({ en: 'English description' })).toBe('English description')
+ expect(locale.resolveText({ en: 'Fallback', zh: '' })).toBe('')
+ expect(locale.getSnapshot()).toBe(snapshot)
+ expect(snapshot.locales.map(item => item.id)).toEqual(['zh', 'en'])
+
+ locale.setLocale('en')
+ expect(locale.resolveText(title)).toBe('Tools')
+ })
+
+ it('walks registered regional fallbacks using lowercase map keys', () => {
+ const { ctx, locale } = make()
+ ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: French')
+ ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
+ locale.setLocale('FR-ca')
+
+ expect(locale.resolveText({ en: 'Tools', fr: 'Outils', 'fr-ca': 'Outils Québec' })).toBe('Outils Québec')
+ expect(locale.resolveText({ en: 'Tools', fr: 'Outils' })).toBe('Outils')
+ expect(locale.resolveText({ en: 'Tools', zh: '工具' })).toBe('Tools')
+ })
+
+ it('uses English across an unloaded fallback and follows its replacement', () => {
+ const { ctx, locale } = make()
+ const removeFrench = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
+ ctx.effect(() => removeFrench, 'test: French')
+ ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
+ locale.setLocale('fr-CA')
+ const text = { en: 'Tools', fr: 'Outils' }
+
+ expect(locale.resolveText(text)).toBe('Outils')
+ removeFrench()
+ expect(locale.resolveText(text)).toBe('Tools')
+ ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: replacement French')
+ expect(locale.resolveText(text)).toBe('Outils')
+ })
+})
diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json
index 8737cc0c01..0d4fa48c3f 100644
--- a/packages/client/locale/tsconfig.json
+++ b/packages/client/locale/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/package-manifest"
+ },
{
"path": "../../../vendor/schemastery"
},
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json
index a5f08aa391..cc7db22e85 100644
--- a/packages/client/ui-plugin-manager/package.json
+++ b/packages/client/ui-plugin-manager/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
index a97e31e8ca..5235d53cd0 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
@@ -26,10 +26,10 @@ import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
import type { PluginManagerLocaleKey } from './locales.ts'
import {
isInstallPending, offeredRegistries, rowKey,
- type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
+ type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
type PluginManagerFace, type RegistryChoice,
} from './manager-store.ts'
-import { managementText, noticeText, packageText, registryText, type Translate } from './presentation.ts'
+import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts'
import type {} from './slot-contract.ts'
import css from './PluginManagerPage.module.css'
@@ -42,6 +42,7 @@ export type PluginManagerPageProps =
/** The page's slot renderer, narrowed to the configuration slots. */
type RenderConfig = PluginManagerPageProps['renderSlot']
+type ResolveText = PluginManagerFace['resolveText']
/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */
type View =
@@ -147,8 +148,9 @@ function packageArtwork(name: string): ReactNode {
}
/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
-function RowSwitch({ row, t, busy, onChange }: {
+function RowSwitch({ row, title, t, busy, onChange }: {
readonly row: PackageRow
+ readonly title: string
readonly t: Translate
readonly busy: boolean
readonly onChange: (enabled: boolean) => void
@@ -157,7 +159,7 @@ function RowSwitch({ row, t, busy, onChange }: {
return (
{t('metadataError', { error })}
+}
+
/**
* A pack's rows as a list in the order the pack declares them: a state dot,
* the row id, one line saying its state, a configure control for a row that
* registered a page, and, when the pack is on, a switch. A pack like base
* carries close to a hundred rows, so a long list gets a filter.
*/
-function RowsSection({ rows, t, toggle, configure }: {
+function RowsSection({ rows, t, resolveText, toggle, configure }: {
readonly rows: readonly PackageRow[]
readonly t: Translate
+ readonly resolveText: ResolveText
readonly toggle?: RowToggles | undefined
readonly configure?: RowConfigure | undefined
}): ReactNode {
const [filter, setFilter] = useState('')
const query = filter.trim().toLowerCase()
- const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query))
+ const localized = rows.map(row => ({ row, ...rowText(row, resolveText) }))
+ const shown = query === '' ? localized : localized.filter(({ row, title, description }) =>
+ [title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query)))
return (
@@ -216,7 +226,7 @@ function RowsSection({ rows, t, toggle, configure }: {
? null
: (
- {shown.map(row => (
+ {shown.map(({ row, title, description }) => (
{configure?.has(row) === true
? (
- { configure.open(row) }}>
- {row.rowId}
+ { configure.open(row) }}>
+ {title}
)
- : {row.rowId} }
- {row.moduleName}
+ : {title} }
+ {description === undefined ? null : {description} }
+ {title === row.rowId ? null : {row.rowId}}
+ {title === row.moduleName ? null : {row.moduleName}}
@@ -242,8 +254,12 @@ function RowsSection({ rows, t, toggle, configure }: {
{toggle === undefined
? null
- : { toggle.onSetEnabled(row, enabled) }} />}
+ : { toggle.onSetEnabled(row, enabled) }}
+ />}
+
))}
@@ -290,15 +306,16 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: {
readonly description: ReactNode
readonly end?: ReactNode
}): ReactNode {
+ const descriptionId = useId()
return (
{icon}
- {title}
+ {title}
{tags}
- {description === undefined ? null :
{description} }
+ {description === undefined ? null :
{description} }
{end === undefined ? null :
{end}
}
@@ -328,15 +345,16 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
}
/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
-function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
+function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: {
readonly pkg: PackageView
readonly t: Translate
+ readonly resolveText: ResolveText
readonly busy: boolean
readonly highlighted: boolean
readonly onOpen: () => void
readonly onSetEnabled: (enabled: boolean) => void
}): ReactNode {
- const { title, description, beta } = packageText(pkg, t)
+ const { title, description, beta } = packageText(pkg, resolveText)
const status = packageStatus(pkg)
return (
}
/>
+
)
}
@@ -404,28 +423,32 @@ function ItemDetail({ item, t, onBack, renderSlot }: {
}
/**
- * A row's configuration page: the crumb back to its bundle's page, the row id
- * over the module it names and the entry's one-liner, and the form the entry renders.
+ * A row's configuration page keeps its technical identity beside local package
+ * text and the form supplied by its configuration entry.
*/
-function RowDetail({ pkg, row, t, onBack, renderSlot }: {
+function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: {
readonly pkg: PackageView
readonly row: PackageRow
readonly t: Translate
+ readonly resolveText: ResolveText
readonly onBack: () => void
readonly renderSlot: RenderConfig
}): ReactNode {
- const { title } = packageText(pkg, t)
+ const { title } = packageText(pkg, resolveText)
+ const { title: rowTitle, description } = rowText(row, resolveText)
const key = rowConfigKey(pkg.name, row.rowId)
return (
} />
-
{row.rowId}
+ {rowTitle}
+ {rowTitle === row.rowId ? null :
{row.rowId}
}
{row.moduleName}
-
{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}
+
{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}
+
{renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
@@ -442,11 +465,12 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: {
* itself; and its rows with their switches and configure controls.
*/
function PackageDetail({
- pkg, t, busy, rowBusy, configured, configure, renderSlot,
+ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot,
onBack, onSetEnabled, onUninstall, onSetRowEnabled,
}: {
readonly pkg: PackageView
readonly t: Translate
+ readonly resolveText: ResolveText
readonly busy: boolean
/** Whether a row has a write in flight. */
readonly rowBusy: (row: PackageRow) => boolean
@@ -459,7 +483,7 @@ function PackageDetail({
readonly onUninstall: () => void
readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
}): ReactNode {
- const { title, description, beta } = packageText(pkg, t)
+ const { title, description, beta } = packageText(pkg, resolveText)
const status = packageStatus(pkg)
return (
@@ -497,8 +521,9 @@ function PackageDetail({
{status === 'problem' ? {t('statusProblem')} : null}
{pkg.name}
-
{description ?? t('noDescription')}
+ {description === undefined ? null :
{description}
}
+
{pkg.error === undefined ? null : {t('reasonLabel')}: {managementText(pkg.error, t)}
}
{pkg.readOnlyReason === undefined ? null : {managementText({ code: pkg.readOnlyReason }, t)}
}
@@ -512,6 +537,7 @@ function PackageDetail({
busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
configure={configure}
/>
@@ -1012,13 +1038,12 @@ function InstallDialog({
}
/** The confirmation an uninstall waits on. */
-function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
- readonly confirm: ConfirmState
+function ConfirmDialog({ name, t, onConfirm, onCancel }: {
+ readonly name: string
readonly t: Translate
readonly onConfirm: () => void
readonly onCancel: () => void
}): ReactNode {
- const { title: name } = packageText({ name: confirm.packageName }, t)
return (
snapshot)
const ledger = props.useConfigLedger(snapshot => snapshot)
// What is open; a package that leaves the list (uninstalled) drops back to the cards.
@@ -1082,6 +1107,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
key={pkg.name}
pkg={pkg}
t={t}
+ resolveText={resolveText}
busy={state.busy.includes(pkg.name)}
highlighted={state.highlight === pkg.name}
onOpen={() => { setView({ kind: 'package', name: pkg.name }) }}
@@ -1165,6 +1191,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
pkg={openPkg}
row={openRow}
t={t}
+ resolveText={resolveText}
renderSlot={renderSlot}
onBack={() => { setView({ kind: 'package', name: openPkg.name }) }}
/>
@@ -1175,6 +1202,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
configured={ledger.bundles.has(openPkg.name)}
@@ -1231,7 +1259,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
? null
: (
pkg.name === state.confirm?.packageName) ?? { name: state.confirm.packageName },
+ resolveText,
+ ).title}
t={t}
onConfirm={props.confirm}
onCancel={props.cancelConfirm}
diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts
index 1e2c9a60b1..f3394b69cc 100644
--- a/packages/client/ui-plugin-manager/src/client/index.ts
+++ b/packages/client/ui-plugin-manager/src/client/index.ts
@@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void {
name: 'main',
key: PANEL_ID,
locale: NS,
- inject: () => controller.inject(configLedger),
+ inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)),
children: {
'plugins.item': { kind: 'list', scope: 'root' },
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts
index 74d367aec6..30116aa2bf 100644
--- a/packages/client/ui-plugin-manager/src/client/locales.ts
+++ b/packages/client/ui-plugin-manager/src/client/locales.ts
@@ -1,4 +1,4 @@
-/** Plugin management copy and display names of shipped global rows. */
+/** Plugin management interface copy. */
/** Simplified Chinese dictionary and key source of truth. */
export const zh = {
@@ -16,15 +16,11 @@ export const zh = {
overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效',
bundlesTitle: '已安装',
officialTitle: '官方',
- builtinAgentTeamTitle: '智能体团队',
- builtinAgentTeamDescription: '启用团队协作、团队工具、成员列表和共享任务看板。',
- builtinAutoReviewTitle: '自动授权审查',
- builtinAutoReviewDescription: '提供自动审查权限模式,由模型在每次工具调用前判断是否授权。',
statusProblem: '异常',
statusBeta: 'Beta',
reasonLabel: '原因',
+ metadataError: '包元信息错误:{error}',
versionTag: 'v{version}',
- noDescription: '暂无描述。',
partsLabel: '包含的组件',
partsEmpty: '这个插件包不包含任何组件。',
partsCountTotal: '共 {count} 个',
@@ -204,15 +200,11 @@ export const en = {
overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect',
bundlesTitle: 'Installed',
officialTitle: 'Official',
- builtinAgentTeamTitle: 'Agent Teams',
- builtinAgentTeamDescription: 'Enable team collaboration, team tools, the member roster, and the shared task board.',
- builtinAutoReviewTitle: 'Auto Authorization Review',
- builtinAutoReviewDescription: 'Add an Auto review permission mode that uses the model to assess authorization before each tool call.',
statusProblem: 'Problem',
statusBeta: 'Beta',
reasonLabel: 'Reason',
+ metadataError: 'Package metadata error: {error}',
versionTag: 'v{version}',
- noDescription: 'No description.',
partsLabel: 'Components',
partsEmpty: 'This plugin pack contains no components.',
partsCountTotal: '{count} total',
diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts
index 533893caa8..2113612733 100644
--- a/packages/client/ui-plugin-manager/src/client/manager-store.ts
+++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts
@@ -27,6 +27,7 @@ import type {
import { REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest'
import type { ConfigLedger } from './config-ledger.ts'
import { shortName } from './presentation.ts'
@@ -59,6 +60,8 @@ export interface PackageRow {
readonly rowId: string
/** The module the row names. */
readonly moduleName: string
+ /** Local package display text and metadata diagnostics supplied by the Host. */
+ readonly meta?: PackageMeta
/** Whether the entry runs; false for a row without a live entry. */
readonly enabled: boolean
/** The entry's fiber phase, null without a live fiber. */
@@ -72,6 +75,8 @@ export interface PackageView {
readonly name: string
readonly version?: string
readonly description?: string
+ /** Local package display text and metadata diagnostics supplied by the Host. */
+ readonly meta?: PackageMeta
/** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */
readonly installed: boolean
/** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */
@@ -220,6 +225,8 @@ export interface PluginManagerState {
/** The registration-side face the tab's slot entry injects. */
export interface PluginManagerFace {
+ /** Resolve local package text in the current Client locale at render time. */
+ resolveText: (text: LocalizedText) => string
hooks: {
/** Tab snapshot bound by the renderer as usePluginManager. */
pluginManager: SnapshotStore
@@ -325,6 +332,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
moduleName: row.moduleName,
enabled: live?.enabled ?? false,
phase: live?.fiberPhase ?? null,
+ ...row.meta === undefined ? {} : { meta: row.meta },
...row.entryId === undefined ? {} : { entryId: row.entryId },
...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason },
}
@@ -337,6 +345,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
rows,
...bundle.version === undefined ? {} : { version: bundle.version },
...bundle.description === undefined ? {} : { description: bundle.description },
+ ...bundle.meta === undefined ? {} : { meta: bundle.meta },
...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason },
...bundle.error === undefined ? {} : { error: bundle.error },
}
@@ -431,10 +440,12 @@ export class PluginManagerController {
/**
* Build the face the tab's slot registration injects.
* @param configLedger - the projection of the plugins carrying configuration, bound beside the tab's own state.
+ * @param resolveText - render-time package text resolution supplied by the locale service.
* @returns the tab's snapshot sources and its actions.
*/
- inject(configLedger: HostObservable): PluginManagerFace {
+ inject(configLedger: HostObservable, resolveText: PluginManagerFace['resolveText']): PluginManagerFace {
return {
+ resolveText,
hooks: { pluginManager: this.store, configLedger },
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
refresh: () => { void this.load() },
diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts
index d3446a13db..c76633ff97 100644
--- a/packages/client/ui-plugin-manager/src/client/presentation.ts
+++ b/packages/client/ui-plugin-manager/src/client/presentation.ts
@@ -3,19 +3,15 @@
import type { ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginManagerLocaleKey } from './locales.ts'
-import type { FailedAction, ManagerNotice, PackageView } from './manager-store.ts'
+import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts'
/** The translate seat of the manager's dictionary. */
export type Translate = PropsLocale<'pluginManager'>['t']
-/** The official packages with copy of their own, and whether each is a beta feature the page tags as such. */
-const BUILTIN_COPY = new Map([
- ['@deepseek-ai/dsh-experimental-agent-team-profile', {
- title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', beta: true,
- }],
- ['@deepseek-ai/dsh-experimental-auto-review', {
- title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', beta: true,
- }],
+/** Official packages whose release status the page identifies as beta. */
+const BETA_PACKAGES = new Set([
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
+ '@deepseek-ai/dsh-experimental-auto-review',
])
/** The registries with a name of their own, by host. */
@@ -98,18 +94,34 @@ export function shortName(name: string): string {
}
/**
- * Localize known official packages by exact npm name at render time.
- * @param pkg - original package identity and optional metadata description.
- * @param t - the manager's current translate function.
- * @returns localized copy and whether the package is a beta feature, or the package's short name and original description.
+ * Resolve installed package metadata without changing its technical identity.
+ * @param pkg - package identity and local metadata.
+ * @param resolveText - current-locale package text resolver.
+ * @returns localized copy with a technical-name fallback and the independent beta status.
*/
export function packageText(
- pkg: Pick, t: Translate,
+ pkg: Pick, resolveText: PluginManagerFace['resolveText'],
): { title: string; description: string | undefined; beta: boolean } {
- const keys = BUILTIN_COPY.get(pkg.name)
- return keys === undefined
- ? { title: shortName(pkg.name), description: pkg.description, beta: false }
- : { title: t(keys.title), description: t(keys.description), beta: keys.beta }
+ return {
+ title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title),
+ description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined,
+ beta: BETA_PACKAGES.has(pkg.name),
+ }
+}
+
+/**
+ * Resolve a bundle row's plugin metadata, using its full module specifier as the final title fallback.
+ * @param row - row identity and local metadata.
+ * @param resolveText - current-locale package text resolver.
+ * @returns the row's display title and optional description.
+ */
+export function rowText(
+ row: Pick, resolveText: PluginManagerFace['resolveText'],
+): { title: string; description: string | undefined } {
+ return {
+ title: row.meta?.title === undefined ? row.moduleName : resolveText(row.meta.title),
+ description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined,
+ }
}
/**
diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts
index 2fd0b41d40..89a9b52115 100644
--- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts
+++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts
@@ -5,9 +5,10 @@
* that row. A registrant merges this contract with `import type` and registers
* through `ctx.slots`; it never imports this package at runtime.
*
- * Every entry is rendered in two views the owner asks for: `summary` for the
- * one-liner the page places under the title, `page` for the form with its own
- * save control. The page draws the title, the icon, and the crumb itself.
+ * Entries accept `page` for the form with its own save control and `summary`
+ * for an official card's one-liner or a row's missing-description fallback.
+ * Bundle configuration renders only `page`. The page draws the title, icon,
+ * and crumb itself.
*/
import type {} from '@deepseek-ai/dsh-client-ui-slots'
@@ -40,7 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The configuration of one row a bundle declares, keyed by
* `#` with the row id as the bundle's patch declares
* it: the row on the bundle's page gains a configure control that opens
- * the entry's page, headed by the row id and the entry's summary.
+ * the entry's page, headed by the plugin's display title and description.
+ * An absent description falls back to the entry's `view: 'summary'`.
*/
'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps }
}
diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
index d3825fc6a0..60194747fe 100644
--- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { Context, Service } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -17,6 +17,7 @@ afterEach(cleanup)
async function bench() {
const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
@@ -83,6 +84,11 @@ describe('ui-plugin-manager browser plugin', () => {
expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
const face = (entry.inject as unknown as () => PluginManagerFace)()
+ const text = { en: 'Local tools', zh: '本地工具' }
+ expect(face.resolveText(text)).toBe('本地工具')
+ b.locale.setLocale('en')
+ expect(face.resolveText(text)).toBe('Local tools')
+ b.locale.setLocale('zh')
expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() })
// A Host change before the first render is not a reason to read.
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
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 22e5d51828..0737596fb5 100644
--- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
@@ -1,6 +1,8 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -69,6 +71,11 @@ type SlotBodies = Record ReactNode>
const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() }
function renderTab(state: Partial = {}, config: Partial = {}, bodies: SlotBodies = {}) {
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
+ const locale = new LocaleRuntime(ctx)
+ locale.setLocale('en')
+ const resolveText: PluginManagerPageProps['resolveText'] = text => locale.resolveText(text)
const store = createSnapshotStore({ ...READY, ...state })
const ledger = createSnapshotStore({ ...NO_CONFIG, ...config })
const actions = {
@@ -96,6 +103,7 @@ function renderTab(state: Partial = {}, config: Partial = {}, config: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
- setLanguage: (dict: typeof en) => { rerender( ) },
+ setLanguage: (dict: typeof en) => {
+ locale.setLocale(dict === zh ? 'zh' : 'en')
+ rerender( )
+ },
}
}
@@ -133,7 +144,7 @@ describe('PluginManagerPage', () => {
it('keeps the read failure and its retry visible while a detail page is open', () => {
const { actions, set } = renderTab({ packages: [pkg()] })
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
expect(screen.queryByRole('alert')).toBeNull()
set({ status: 'error' })
expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull()
@@ -146,7 +157,7 @@ describe('PluginManagerPage', () => {
it('lists the installed bundles as cards, the installation\'s offered ones as official, and tags a problem the Host reports', () => {
const { actions } = renderTab({
packages: [
- pkg({ description: 'A sidebar.' }),
+ pkg({ meta: { description: { en: 'A sidebar.' } } }),
pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }),
pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }),
pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }),
@@ -168,15 +179,14 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('heading', { name: en.officialTitle })).toBeTruthy()
expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5'])
expect(screen.getAllByText(en.statusBeta)).toHaveLength(1)
- // A scoped name reads without its scope and harness prefix.
- expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false)
+ expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', '@acme/dsh-tool') })).toHaveProperty('disabled', false)
expect(screen.getByText('A sidebar.')).toBeTruthy()
expect(screen.getAllByText(en.statusProblem)).toHaveLength(2)
// The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is.
- fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') }))
expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
- expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true)
- const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') })
+ expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-broken') })).toHaveProperty('disabled', true)
+ const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-protected') })
expect(locked).toHaveProperty('disabled', true)
expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired)
})
@@ -215,52 +225,258 @@ describe('PluginManagerPage', () => {
})
it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => {
+ const title = 'Agent Teams'
const { actions } = renderTab({
- packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],
+ packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', meta: { title }, installed: false, optional: true, enabled: false })],
})
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) }))
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
expect(within(detail).getByText(en.statusBeta)).toBeTruthy()
- expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull()
- fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) }))
+ expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull()
+ fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', title) }))
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
})
it.each([
- ['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'],
- ['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'],
- ] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => {
- const name = `@deepseek-ai/dsh-experimental-${suffix}`
- const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] })
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
+ '@deepseek-ai/dsh-experimental-auto-review',
+ '@acme/dsh-local-tools',
+ ])('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name) => {
+ const meta = {
+ title: { en: 'Installed tools', zh: '已安装工具' },
+ description: { en: 'Tools on this host.', zh: '本机工具。' },
+ }
+ const title = (dict: typeof en): string => dict === zh ? meta.title.zh : meta.title.en
+ const description = (dict: typeof en): string => dict === zh ? meta.description.zh : meta.description.en
+ const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, meta, description: 'Original metadata.' })] })
const assertCard = (dict: typeof en) => {
- expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey])
- expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
- expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
+ const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title(dict)) })
+ expect(card.textContent).toBe(title(dict))
+ expect(screen.getByText(description(dict))).toBeTruthy()
+ expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description(dict))
+ expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
expect(screen.queryByText('Original metadata.')).toBeNull()
}
assertCard(en)
setLanguage(zh)
assertCard(zh)
- fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) }))
+ fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) }))
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
- fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) }))
+ fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) }))
for (const dict of [zh, en]) {
setLanguage(dict)
- expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey])
- expect(screen.getByText(dict[descriptionKey])).toBeTruthy()
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict))
+ expect(screen.getByText(description(dict))).toBeTruthy()
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
- expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy()
- expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy()
+ expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
+ expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', title(dict)) })).toBeTruthy()
}
- fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) }))
+ fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', title(en)) }))
expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name)
set({ confirm: { action: 'uninstall', packageName: name } })
for (const dict of [en, zh]) {
setLanguage(dict)
- expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy()
+ expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', title(dict)) })).toBeTruthy()
}
})
+ it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => {
+ const error = 'locale/zh.json: invalid title'
+ const { actions, set, setLanguage } = renderTab({
+ packages: [pkg({ enabled: false, description: 'Legacy description.', meta: { error } })],
+ })
+ expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
+ expect(screen.queryByText('Legacy description.')).toBeNull()
+ const enable = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })
+ expect(enable).toHaveProperty('disabled', false)
+ fireEvent.click(enable)
+ expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true)
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
+ expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
+ const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })
+ expect(uninstall).toHaveProperty('disabled', false)
+ fireEvent.click(uninstall)
+ expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar')
+
+ setLanguage(zh)
+ expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
+ set({ packages: [pkg({ meta: { title: 'Literal title', description: { en: 'English fallback.' } } })] })
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Literal title')
+ expect(screen.getByText('English fallback.')).toBeTruthy()
+ expect(document.querySelector('[data-package-meta-error]')).toBeNull()
+ })
+
+ it.each<{
+ field: string
+ meta: NonNullable
+ englishTitle: string
+ chineseTitle: string
+ englishDescription?: string
+ chineseDescription: string
+ }>([
+ {
+ field: 'title',
+ meta: { title: { en: '@acme/dsh-sidebar', zh: '侧栏套件' }, description: 'Package description.' },
+ englishTitle: '@acme/dsh-sidebar', chineseTitle: '侧栏套件',
+ englishDescription: 'Package description.', chineseDescription: 'Package description.',
+ },
+ {
+ field: 'description',
+ meta: { title: { en: 'English title' }, description: { en: '', zh: '中文套件说明。' } },
+ englishTitle: 'English title', chineseTitle: 'English title', chineseDescription: '中文套件说明。',
+ },
+ ])('resolves bundle $field independently and hides empty descriptions on cards and details', ({ meta, englishTitle, chineseTitle, englishDescription, chineseDescription }) => {
+ const { setLanguage } = renderTab({ packages: [pkg({ name: '@acme/dsh-sidebar', meta })] })
+ const languages = [
+ { dict: en, title: englishTitle, description: englishDescription },
+ { dict: zh, title: chineseTitle, description: chineseDescription },
+ { dict: en, title: englishTitle, description: englishDescription },
+ ]
+ for (const { dict, title, description } of languages) {
+ setLanguage(dict)
+ const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title) })
+ expect(card.textContent).toBe(title)
+ if (description === undefined) {
+ expect(card.getAttribute('aria-describedby')).toBeNull()
+ expect(screen.queryByText(chineseDescription)).toBeNull()
+ } else {
+ expect(screen.getByText(description)).toBeTruthy()
+ expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description)
+ }
+ }
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', englishTitle) }))
+ for (const { dict, title, description } of languages) {
+ setLanguage(dict)
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title)
+ if (description === undefined) {
+ expect(screen.queryByText(chineseDescription)).toBeNull()
+ expect(screen.queryByText(dict === zh ? '暂无描述。' : 'No description.')).toBeNull()
+ } else {
+ expect(screen.getByText(description)).toBeTruthy()
+ }
+ }
+ })
+
+ it('resolves row fields independently from bundle metadata and preserves subpath specifiers', () => {
+ const rows = [
+ row({ moduleName: '@acme/dsh-sidebar/navigation', meta: { description: { en: 'Navigation description.' } } }),
+ row({
+ rowId: 'theme', entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client',
+ meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } },
+ }),
+ ]
+ const { actions, setLanguage } = renderTab(
+ { packages: [pkg({ meta: { title: 'Bundle title', description: 'Bundle description.' }, rows })] },
+ { rows: new Set(['dsh-better-sidebar#theme']) },
+ { 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ? : null },
+ )
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') }))
+ expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy()
+ expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy()
+ expect(screen.queryByText('中文主题说明。')).toBeNull()
+ setLanguage(zh)
+ const navigation = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement
+ expect(within(navigation).getByText('Navigation description.')).toBeTruthy()
+ expect(within(navigation).queryByText('Bundle title')).toBeNull()
+ expect(within(navigation).queryByText('Bundle description.')).toBeNull()
+ fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') }))
+ expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false)
+ expect(screen.getByText('中文主题说明。')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') }))
+ expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme')
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件')
+ expect(screen.getByText('中文主题说明。')).toBeTruthy()
+ setLanguage(en)
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('@acme/dsh-theme')
+ expect(screen.getByText('@acme/dsh-theme/client')).toBeTruthy()
+ expect(screen.getByText('theme')).toBeTruthy()
+ expect(screen.queryByText('中文主题说明。')).toBeNull()
+ expect(screen.getByRole('form', { name: 'theme settings' })).toBeTruthy()
+ })
+
+ it('shows a row id only once when it is the localized title in lists and configuration pages', () => {
+ const moduleName = '@acme/dsh-sidebar/navigation'
+ const { setLanguage } = renderTab(
+ { packages: [pkg({ rows: [row({ moduleName, meta: { title: { en: 'Sidebar component', zh: 'sidebar' } } })] })] },
+ { rows: new Set(['dsh-better-sidebar#sidebar']) },
+ { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? : null },
+ )
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
+ const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement
+ expect(within(listed).getByText('Sidebar component')).toBeTruthy()
+ expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy()
+ setLanguage(zh)
+ expect(within(listed).getAllByText('sidebar')).toHaveLength(1)
+ expect(within(listed).queryByText('sidebar', { selector: 'code' })).toBeNull()
+ expect(within(listed).getByText(moduleName, { selector: 'code' })).toBeTruthy()
+
+ fireEvent.click(within(listed).getByRole('button', { name: zh.configureRow.replace('{name}', 'sidebar') }))
+ const detail = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement
+ expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('sidebar')
+ expect(within(detail).getAllByText('sidebar')).toHaveLength(1)
+ expect(within(detail).queryByText('sidebar', { selector: 'code' })).toBeNull()
+ expect(within(detail).getByText(moduleName, { selector: 'code' })).toBeTruthy()
+ expect(within(detail).getByRole('form', { name: 'sidebar settings' })).toBeTruthy()
+ setLanguage(en)
+ expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component')
+ expect(within(detail).getByText('sidebar', { selector: 'code' })).toBeTruthy()
+ })
+
+ it('translates row text, searches current copy and technical identities, and keeps configuration keys unchanged', () => {
+ const error = 'locale/zh.json: invalid description'
+ const localized = row({
+ moduleName: '@acme/dsh-sidebar-widget',
+ meta: {
+ title: { en: 'Sidebar component', zh: '导航组件' },
+ description: { en: 'Sidebar navigation', zh: '侧边导航' },
+ error,
+ },
+ })
+ const rows = [localized, ...Array.from({ length: 10 }, (_, index) => row({
+ rowId: `extra-${String(index)}`, entryId: `include:extra-${String(index)}` as PluginEntryId, moduleName: '@acme/other',
+ }))]
+ const { actions, setLanguage } = renderTab(
+ { packages: [pkg({ meta: { title: { en: 'Personal tools', zh: '个人工具' } }, rows })] },
+ { rows: new Set(['dsh-better-sidebar#sidebar']) },
+ { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'Config summary' : },
+ )
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Personal tools') }))
+ setLanguage(zh)
+ const search = screen.getByRole('searchbox', { name: zh.partsFilter })
+ for (const query of ['导航组件', '侧边导航', 'sidebar', '@acme/dsh-sidebar-widget']) {
+ fireEvent.change(search, { target: { value: query } })
+ expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(1)
+ expect(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '导航组件') })).toBeTruthy()
+ }
+ expect(screen.getByText('sidebar')).toBeTruthy()
+ expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy()
+ expect(screen.getByText('侧边导航')).toBeTruthy()
+ expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
+ const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') })
+ expect(toggle).toHaveProperty('disabled', false)
+ fireEvent.click(toggle)
+ expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false)
+
+ fireEvent.change(search, { target: { value: 'Sidebar component' } })
+ expect(screen.getByText(zh.partsFilterEmpty)).toBeTruthy()
+ setLanguage(en)
+ expect(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') })).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') }))
+ expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#sidebar')
+ expect(screen.getByRole('form', { name: 'row settings' })).toBeTruthy()
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component')
+ expect(screen.getByText('Sidebar navigation')).toBeTruthy()
+ expect(screen.queryByText('Config summary')).toBeNull()
+ setLanguage(zh)
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('导航组件')
+ expect(screen.getByText('侧边导航')).toBeTruthy()
+ expect(screen.getByText('sidebar')).toBeTruthy()
+ expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy()
+ expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: zh.backToPackage.replace('{name}', '个人工具') }))
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('个人工具')
+ })
+
describe('configuration pages', () => {
const bodies: SlotBodies = {
'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' : ,
@@ -306,35 +522,36 @@ describe('PluginManagerPage', () => {
it('renders a bundle\'s own configuration on its page, and no configure control on a row without one', () => {
renderTab({ packages: [pkg({ rows: [row()] })] }, { bundles: new Set(['dsh-better-sidebar']) }, bodies)
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
expect(within(detail).getByRole('form', { name: 'sidebar form' })).toBeTruthy()
- expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })).toBeNull()
+ expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })).toBeNull()
})
it('opens a row\'s configuration page from its configure control and leads back to the bundle', () => {
const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId })
renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies)
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
- expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'theme') })).toBeNull()
- fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
+ expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') }))
const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement
- expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('sidebar')
- expect(within(page).getByText('dsh-better-sidebar')).toBeTruthy()
+ expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar')
+ expect(within(page).getByText('dsh-better-sidebar', { selector: 'code' })).toBeTruthy()
+ expect(within(page).getByText('sidebar', { selector: 'code' })).toBeTruthy()
expect(within(page).getByText('The sidebar row.')).toBeTruthy()
expect(within(page).getByRole('form', { name: 'row form' })).toBeTruthy()
- fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'dsh-better-sidebar') }))
expect(document.querySelector('[data-plugin-row-detail]')).toBeNull()
expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).toBeTruthy()
})
})
- it('preserves metadata for another scope with the same short name', () => {
+ it('preserves the full package name and description for a third-party scope', () => {
const name = '@acme/dsh-experimental-agent-team-profile'
- const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] })
+ const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] })
setLanguage(zh)
- fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
- expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile')
+ fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) }))
+ expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name)
expect(screen.getByText('Third-party description.')).toBeTruthy()
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
})
@@ -357,16 +574,15 @@ describe('PluginManagerPage', () => {
it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => {
const { actions, set } = renderTab({
packages: [pkg({
- description: 'A sidebar.',
+ meta: { description: { en: 'A sidebar.' } },
rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })],
})],
})
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement
- expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('better-sidebar')
+ expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar')
// The version sits beside the name as a tag; the crumb only leads back.
expect(within(detail).getByText('v0.16.0')).toBeTruthy()
- // The full package name stays visible under the short name.
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar')
expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot)
expect(within(detail).getByText('A sidebar.')).toBeTruthy()
@@ -375,18 +591,18 @@ describe('PluginManagerPage', () => {
expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme'])
expect(rows[1]?.getAttribute('data-state')).toBe('off')
expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy()
- expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy()
+ expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar/theme') })).toBeTruthy()
expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy()
expect(within(detail).getByText(en.partOff)).toBeTruthy()
- fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') }))
expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
- fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') }))
expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
// A problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb.
set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] })
expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy()
expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy()
- expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true)
+ expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
expect(within(detail).getByText(en.partsEmpty)).toBeTruthy()
set({ packages: [pkg({ error: { code: 'not-bundle' } })] })
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy()
@@ -394,11 +610,11 @@ describe('PluginManagerPage', () => {
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy()
fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
- // A bundle without a description or a version says so; one that leaves the list drops back to the cards.
+ // A bundle that leaves the list drops back to the cards.
const { version: _version, ...unversioned } = pkg()
set({ packages: [unversioned] })
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
- expect(screen.getByText(en.noDescription)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
+ expect(screen.queryByText('No description.')).toBeNull()
expect(screen.queryByText(en.versionTag.replace('{version}', '0.16.0'))).toBeNull()
set({ packages: [] })
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
@@ -420,19 +636,21 @@ describe('PluginManagerPage', () => {
return { ...unmounted, enabled: false, phase: null }
})
const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] })
- fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
+ const toggle = (id: string): HTMLElement => within(detail.querySelector(`[data-plugin-row="${id}"]`)!)
+ .getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') })
expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '8')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy()
- fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') }))
+ fireEvent.click(toggle('include:row-0'))
expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false)
// A protected row, a row without a live entry, and a row with a write in flight cannot be switched.
- const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') })
+ const locked = toggle('include:row-1')
expect(locked).toHaveProperty('disabled', true)
expect(locked.getAttribute('title')).toBe(en.reasonUnaddressable)
- expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true)
- fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') }))
+ expect(toggle('row-2')).toHaveProperty('disabled', true)
+ fireEvent.click(toggle('row-2'))
expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
- expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true)
+ expect(toggle('include:row-5')).toHaveProperty('disabled', true)
expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy()
@@ -448,7 +666,7 @@ describe('PluginManagerPage', () => {
fireEvent.change(filter, { target: { value: '' } })
// A bundle that is off shows its rows without switches.
set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] })
- expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull()
+ expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') })).toBeNull()
expect(within(detail).getAllByText(en.partOff)).toHaveLength(2)
// A row without a fiber, on a bundle that is on, reads idle.
set({ packages: [pkg({ rows: [row({ phase: null })] })] })
@@ -841,12 +1059,14 @@ describe('PluginManagerPage', () => {
packages: [pkg(), pkg({ name: 'dsh-other' })],
confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' },
})
- expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy()
+ expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy()
expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
expect(actions.cancelConfirm).toHaveBeenCalledTimes(1)
set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } })
- expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy()
+ expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy()
+ set({ packages: [] })
+ expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall }))
expect(actions.confirm).toHaveBeenCalledTimes(1)
})
diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
index e60bd80ee8..939d54fbf7 100644
--- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
+++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
@@ -3,7 +3,7 @@
* outcomes become toasts, and how the install run folds its output.
*/
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
@@ -87,7 +87,8 @@ function bench(overrides: Partial>> = {}
}
const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never
const controller = new PluginManagerController(ctx)
- const face = controller.inject(NO_CONFIG)
+ onTestFinished(() => { controller.dispose() })
+ const face = controller.inject(NO_CONFIG, text => typeof text === 'string' ? text : text.en)
const state = () => controller.getSnapshot()
/** The request id of the run the dialog just handed to the Host. */
const started = async (): Promise => {
@@ -172,6 +173,25 @@ describe('PluginManagerController', () => {
install.resolve(ok({ ...failed(), application: 'cancelled' }))
})
+ it('keeps local package and row metadata in the loaded view without changing technical identities', async () => {
+ const meta = { title: { en: 'Sidebar', zh: '侧栏' }, description: 'Local package', error: 'locale/zh.json: invalid title' }
+ const rowMeta = { title: { en: 'Theme', zh: '主题' }, description: { en: 'Display options', zh: '显示选项' } }
+ const bundle: BundleInfo = {
+ ...BUNDLE,
+ meta,
+ rows: BUNDLE.rows.map(row => ({ ...row, meta: rowMeta })),
+ }
+ const { controller, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([bundle])) })
+
+ await controller.load()
+
+ expect(state().packages[0]).toMatchObject({ name: BUNDLE.name, meta })
+ expect(state().packages[0]!.meta).toBe(meta)
+ expect(state().packages[0]!.rows[0]).toMatchObject({ rowId: 'sidebar', moduleName: BUNDLE.name, entryId: ROW_ENTRY, meta: rowMeta })
+ expect(state().packages[0]!.rows[0]!.meta).toBe(rowMeta)
+ expect(state().packages[0]!.error).toBeUndefined()
+ })
+
it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
const gate = deferred>>()
const { plugins, inventory, face, state, controller } = bench({
diff --git a/packages/client/ui-plugin-manager/tsconfig.json b/packages/client/ui-plugin-manager/tsconfig.json
index 3cd74d0ad4..931e716c86 100644
--- a/packages/client/ui-plugin-manager/tsconfig.json
+++ b/packages/client/ui-plugin-manager/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/package-manifest"
+ },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json
index 8915a02331..1f98f2a539 100644
--- a/packages/client/ui-settings-plugin-inventory/package.json
+++ b/packages/client/ui-settings-plugin-inventory/package.json
@@ -53,6 +53,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
index 50cb060116..c5064e5441 100644
--- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
+++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
@@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
+import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import {
IconChevronDownOutlineRegular,
IconSearchOutlineRegular,
@@ -20,6 +21,8 @@ type AgentPresetRow = AgentPresetGroup['rows'][number]
/** Registration-side Remote face used by the section. */
export interface PluginInventorySettingsTabInjected {
+ /** Resolve local package text in the current Client locale at render time. */
+ resolveText: (text: LocalizedText) => string
/** Page-local module synchronization, independent from the Host inventory. */
hooks: { clientSync: ObservableSnapshot }
/** Retry the latest client graph without changing the Host composition. */
@@ -60,7 +63,7 @@ function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
}
-/** Compact a module specifier without guessing whether its Loader id was generated. */
+/** Compact technical names for Settings without changing their module identity. */
function moduleShortName(moduleName: string): string {
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
return unscoped
@@ -74,11 +77,21 @@ function entrySubtitle(entryId: string): string {
return entryId.replace(/^include:/, '')
}
-/** Whether one row's module name or entry id matches the catalog query. */
-function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean {
+/** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */
+function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) {
+ const title = row.meta?.title
+ return {
+ title: typeof title === 'object' ? resolveText(title) : moduleShortName(title ?? row.moduleName),
+ description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined,
+ }
+}
+
+/** Match translated text alongside the row's technical module and entry identities. */
+function matches(row: PluginInventoryEntry | AgentPresetRow, normalizedQuery: string, resolveText: PluginInventorySettingsTabInjected['resolveText']): boolean {
if (normalizedQuery.length === 0) return true
- return [moduleName, ...entryId === null ? [] : [entryId]]
- .some(value => value.toLocaleLowerCase().includes(normalizedQuery))
+ const { title, description } = pluginText(row, resolveText)
+ return [row.moduleName, row.entryId, title, description]
+ .some(value => value?.toLocaleLowerCase().includes(normalizedQuery))
}
/** The roster row shown when the preset switcher has no explicit choice. */
@@ -95,9 +108,14 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset
}
/** One expandable plugin card; the caller owns the trailing status content. */
-function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: {
+function PluginCard({
+ rowKey, moduleName, title, description, metadataError, entryId, trailing, ariaLabel, failed, expanded, onToggle, children,
+}: {
readonly rowKey: string
readonly moduleName: string
+ readonly title: string
+ readonly description: string | undefined
+ readonly metadataError: string | undefined
readonly entryId: string | null
readonly trailing: ReactNode
readonly ariaLabel: string
@@ -108,6 +126,7 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed,
}): ReactNode {
const open = expanded === rowKey
const detailId = `plugin-details-${encodeURIComponent(rowKey)}`
+ const descriptionId = useId()
return (
{ onToggle(rowKey) }}
>
- {moduleShortName(moduleName)}
+ {title}
{trailing}
+ {description === undefined ? null : {description} }
{entryId === null ? null : {entrySubtitle(entryId)}}
+ {metadataError === undefined ? null : {metadataError}
}
{open ? {children}
: null}
)
@@ -212,7 +234,7 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab
/** Render the read-only plugin inventory: agent presets first, then the global plane. */
export function PluginInventorySettingsTab(
- { list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
+ { list, presetName, resolveText, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
): ReactNode {
const clientSync = useClientSync(snapshot => snapshot)
const sectionId = useId()
@@ -262,8 +284,8 @@ export function PluginInventorySettingsTab(
else regularEntries.push(entry)
}
- const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery)
- const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery)
+ const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry, normalizedQuery, resolveText)
+ const rowMatch = (row: AgentPresetRow): boolean => matches(row, normalizedQuery, resolveText)
const filteredFailed = failedEntries.filter(entryMatch)
const filteredRegular = regularEntries.filter(entryMatch)
const globalCount = filteredFailed.length + filteredRegular.length
@@ -291,7 +313,7 @@ export function PluginInventorySettingsTab(
/** Trailing status and detail facts for one row of the selected preset. */
const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => {
const key = `preset:${preset.id}:${String(index)}`
- const title = moduleShortName(row.moduleName)
+ const { title, description } = pluginText(row, resolveText)
const failed = row.fiberPhase === 'failed'
const stateText = failed
? t('failedTag')
@@ -302,6 +324,9 @@ export function PluginInventorySettingsTab(
key={key}
rowKey={key}
moduleName={row.moduleName}
+ title={title}
+ description={description}
+ metadataError={row.meta?.error === undefined ? undefined : t('metadataError', { error: row.meta.error })}
entryId={row.entryId}
failed={failed}
expanded={expanded}
@@ -337,7 +362,7 @@ export function PluginInventorySettingsTab(
providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]],
): ReactNode => {
const key = `global:${entry.entryId}`
- const title = moduleShortName(entry.moduleName)
+ const { title, description } = pluginText(entry, resolveText)
const failed = entry.fiberPhase === 'failed'
const stateText = failed
? t('failedTag')
@@ -348,6 +373,9 @@ export function PluginInventorySettingsTab(
key={key}
rowKey={key}
moduleName={entry.moduleName}
+ title={title}
+ description={description}
+ metadataError={entry.meta?.error === undefined ? undefined : t('metadataError', { error: entry.meta.error })}
entryId={entry.entryId}
failed={failed}
expanded={expanded}
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts
index 026f7da686..7d7117554f 100644
--- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts
+++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts
@@ -48,6 +48,7 @@ export function apply(ctx: ClientContext): void {
presetDisplayText(preset, agentPresetCopy).name
const injected = (): PluginInventorySettingsTabInjected => ({
list, presetName,
+ resolveText: text => ctx.locale.resolveText(text),
hooks: { clientSync: ctx.modules.entries.state },
retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) },
})
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts
index 11e8986eef..e4063d238c 100644
--- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts
+++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts
@@ -8,6 +8,7 @@ export const zh = {
clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。',
clientSyncRetry: '重试本页面同步',
error: '暂时无法读取插件。',
+ metadataError: '包元信息错误:{error}',
retry: '重试',
search: '搜索插件',
empty: '暂无插件。',
@@ -54,6 +55,7 @@ export const en = {
clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.',
clientSyncRetry: 'Retry this page',
error: 'Plugins are temporarily unavailable.',
+ metadataError: 'Package metadata error: {error}',
retry: 'Retry',
search: 'Search plugins',
empty: 'No plugins are available.',
diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
index 0dcaa5758a..22b7e218ae 100644
--- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
+++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
@@ -2,7 +2,7 @@
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
import { Context, Service } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -23,6 +23,7 @@ type ListResult =
async function bench() {
const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
const retryClient = vi.fn(async () => {})
ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader)
await ctx.plugin(SlotRegistry).await()
@@ -69,6 +70,11 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
+ const text = { en: 'Local tools', zh: '本地工具' }
+ expect(injected.resolveText(text)).toBe('本地工具')
+ b.locale.setLocale('en')
+ expect(injected.resolveText(text)).toBe('Local tools')
+ b.locale.setLocale('zh')
injected.retryClient()
const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
b.retryClient.mockRejectedValueOnce(new Error('retry unavailable'))
diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
index 7c727f260e..de7274e7a5 100644
--- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
+++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
@@ -1,15 +1,18 @@
// @vitest-environment jsdom
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
+import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
+import { Context } from '@deepseek-ai/cordis'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
import type {
PluginInventorySettingsTabInjected,
PluginInventorySettingsTabProps,
} from '../src/client/PluginInventorySettingsTab.tsx'
-import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
+import { en, zh, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -23,16 +26,32 @@ const t = ((key: PluginInventoryLocaleKey, params?: Record): str
function props(
list: PluginInventorySettingsTabInjected['list'],
presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id,
+ resolveText: PluginInventorySettingsTabInjected['resolveText'] = text => typeof text === 'string' ? text : text.en,
): PluginInventorySettingsTabProps {
return {
t,
list,
presetName,
+ resolveText,
useClientSync: bindSnapshotSelector(createSnapshotStore({ syncing: false, failures: [] })),
retryClient: vi.fn(),
} as PluginInventorySettingsTabProps
}
+function localizedProps(list: PluginInventorySettingsTabInjected['list']) {
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
+ const locale = new LocaleRuntime(ctx)
+ ctx.effect(() => locale.register('settings.pluginInventory', 'en', en), 'test: English inventory dictionary')
+ ctx.effect(() => locale.register('settings.pluginInventory', 'zh', zh), 'test: Chinese inventory dictionary')
+ locale.setLocale('en')
+ const pageProps: PluginInventorySettingsTabProps = {
+ ...props(list, preset => preset.name ?? preset.id, text => locale.resolveText(text)),
+ t: locale.bind('settings.pluginInventory'),
+ }
+ return { locale, pageProps }
+}
+
/** A deployment with a roster: one failed global row, two preset-provided rows. */
const SNAPSHOT = {
entries: [
@@ -90,6 +109,208 @@ const globalToggle = (): HTMLElement =>
const presetToggle = (): HTMLElement => screen.getByRole('button', { name: en.presetTitle })
describe('PluginInventorySettingsTab', () => {
+ it.each(['global', 'preset'])('shortens package and module name fallbacks in the %s inventory', async (scope) => {
+ const names = [
+ ['@deepseek-ai/dsh-tool-subagent', 'tool-subagent'],
+ ['@deepseek-ai/dsh-host-web', 'web'],
+ ['@deepseek-ai/dsh-client-tabs', 'tabs'],
+ ['@deepseek-ai/cordis-plugin-hmr', 'hmr'],
+ ['cordis:timer', 'timer'],
+ ['@acme/dsh-sidebar/navigation', 'sidebar/navigation'],
+ ['plain-plugin', 'plain-plugin'],
+ ] as const
+ const rows = names.flatMap(([moduleName], index) => [false, true].map(fromManifest => ({
+ entryId: `include:short-${String(index)}-${String(fromManifest)}` as PluginEntryId,
+ moduleName,
+ enabled: true,
+ fiberPhase: null,
+ ...fromManifest ? { meta: { title: moduleName, description: 'Package description.' } } : {},
+ })))
+ const snapshot: Snapshot = scope === 'global'
+ ? { entries: rows }
+ : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] }
+ const view = await renderReady(snapshot)
+ fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
+ for (const [index, [moduleName, title]] of names.entries()) {
+ for (const fromManifest of [false, true]) {
+ const entryId = `include:short-${String(index)}-${String(fromManifest)}`
+ const card = screen.getByRole('button', { name: `${title}, ${entryId}, Enabled` })
+ if (fromManifest) {
+ expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe('Package description.')
+ }
+ fireEvent.click(card)
+ expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe(moduleName)
+ expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe(entryId)
+ fireEvent.click(card)
+ }
+ }
+ fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
+ target: { value: '@deepseek-ai/dsh-tool-subagent' },
+ })
+ expect(screen.getAllByRole('listitem')).toHaveLength(2)
+ expect(screen.getAllByRole('button', { name: /^tool-subagent, include:short-0-/ })).toHaveLength(2)
+ })
+
+ it('keeps authored locale titles verbatim when they resemble module names', async () => {
+ const { locale, pageProps } = localizedProps(async () => ({
+ entries: [{
+ entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-navigation', enabled: true, fiberPhase: null,
+ meta: { title: { en: 'dsh-Navigation', zh: 'dsh-导航' } },
+ }],
+ }))
+ const view = render( )
+ await screen.findByRole('searchbox', { name: en.search })
+ fireEvent.click(globalToggle())
+ expect(screen.getByRole('button', { name: 'dsh-Navigation, include:navigation, Enabled' })).toBeTruthy()
+ locale.setLocale('zh')
+ view.rerender( )
+ expect(screen.getByRole('button', { name: 'dsh-导航, include:navigation, 已启用' })).toBeTruthy()
+ })
+
+ it('localizes global and preset metadata at render time while preserving identities and query state', async () => {
+ const globalError = 'locale/zh.json: invalid title'
+ const presetError = 'locale/fr.json: invalid description'
+ const snapshot: Snapshot = {
+ entries: [{
+ entryId: 'include:global-navigation' as PluginEntryId, moduleName: '@acme/navigation', enabled: true, fiberPhase: 'active',
+ meta: {
+ title: { en: 'Navigation', zh: '导航' },
+ description: { en: 'Global navigation controls', zh: '全局导航控件' },
+ error: globalError,
+ },
+ }],
+ agentPresets: [{
+ id: 'custom', trust: 'user', name: 'My preset', isDefault: true,
+ rows: [{
+ entryId: 'include:preset-runner', moduleName: '@acme/runner', enabled: true, fiberPhase: null,
+ meta: {
+ title: { en: 'Session runner', zh: '会话执行器' },
+ description: { en: 'Run per session', zh: '运行会话命令' },
+ error: presetError,
+ },
+ }],
+ }],
+ }
+ const list = vi.fn(async () => snapshot)
+ const { locale, pageProps } = localizedProps(list)
+ const view = render( )
+ await screen.findByRole('searchbox', { name: en.search })
+ fireEvent.click(globalToggle())
+ fireEvent.click(presetToggle())
+ const global = screen.getByRole('button', { name: 'Navigation, include:global-navigation, Enabled' })
+ const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })
+ expect(document.getElementById(global.getAttribute('aria-describedby')!)?.textContent).toBe('Global navigation controls')
+ expect(document.getElementById(preset.getAttribute('aria-describedby')!)?.textContent).toBe('Run per session')
+ expect(screen.getByText(en.metadataError.replace('{error}', globalError))).toBeTruthy()
+ expect(screen.getByText(en.metadataError.replace('{error}', presetError))).toBeTruthy()
+ expect(global.closest('li')?.getAttribute('data-failed')).toBeNull()
+ fireEvent.click(global)
+ expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/navigation')
+ fireEvent.click(preset)
+ expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/runner')
+ expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner')
+
+ locale.setLocale('zh')
+ view.rerender( )
+ expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy()
+ expect(screen.getByText('全局导航控件')).toBeTruthy()
+ expect(screen.getByText('运行会话命令')).toBeTruthy()
+ expect(screen.getByText(zh.metadataError.replace('{error}', presetError))).toBeTruthy()
+ expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner')
+ const search = screen.getByRole('searchbox', { name: zh.search })
+ for (const query of ['导航', '全局导航控件', '@acme/navigation', 'include:global-navigation']) {
+ fireEvent.change(search, { target: { value: query } })
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
+ expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy()
+ }
+ fireEvent.change(search, { target: { value: '运行会话命令' } })
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
+ expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy()
+ fireEvent.change(search, { target: { value: 'Run per session' } })
+ expect(screen.getByText(zh.emptySearch)).toBeTruthy()
+ locale.setLocale('en')
+ view.rerender( )
+ expect(screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })).toBeTruthy()
+ expect(screen.getByRole('searchbox', { name: en.search })).toHaveProperty('value', 'Run per session')
+ expect(list).toHaveBeenCalledOnce()
+ })
+
+ it.each(['global', 'preset'])('resolves each %s row field independently and hides empty English descriptions', async (scope) => {
+ const rows: Snapshot['entries'] = [
+ {
+ entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-sidebar/navigation', enabled: true, fiberPhase: null,
+ meta: { description: { en: 'Navigation description.' } },
+ },
+ {
+ entryId: 'include:commands' as PluginEntryId, moduleName: '@acme/dsh-sidebar/commands', enabled: true, fiberPhase: null,
+ meta: { title: { en: 'English title' }, description: { en: 'Package description.', zh: '中文命令说明。' } },
+ },
+ {
+ entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client', enabled: true, fiberPhase: null,
+ meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } },
+ },
+ ]
+ const snapshot: Snapshot = scope === 'global'
+ ? { entries: rows }
+ : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] }
+ const list = vi.fn(async () => snapshot)
+ const { locale, pageProps } = localizedProps(list)
+ const view = render( )
+ await screen.findByRole('searchbox', { name: en.search })
+ fireEvent.click(scope === 'global' ? globalToggle() : presetToggle())
+ expect(screen.getByRole('button', { name: 'sidebar/navigation, include:navigation, Enabled' })).toBeTruthy()
+ expect(screen.getByText('Navigation description.')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'English title, include:commands, Enabled' })).toBeTruthy()
+ expect(screen.getByText('Package description.')).toBeTruthy()
+ const theme = screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' })
+ expect(theme.getAttribute('aria-describedby')).toBeNull()
+ expect(screen.queryByText('中文主题说明。')).toBeNull()
+
+ locale.setLocale('zh')
+ view.rerender( )
+ for (const [name, description] of [
+ ['sidebar/navigation, include:navigation, 已启用', 'Navigation description.'],
+ ['English title, include:commands, 已启用', '中文命令说明。'],
+ ['主题插件, include:theme, 已启用', '中文主题说明。'],
+ ] as const) {
+ const card = screen.getByRole('button', { name })
+ expect(screen.getByText(description)).toBeTruthy()
+ expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description)
+ }
+ expect(screen.queryByText('Package description.')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: '主题插件, include:theme, 已启用' }))
+ expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:theme')
+ expect(screen.getByText(zh.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-theme/client')
+
+ fireEvent.change(screen.getByRole('searchbox', { name: zh.search }), { target: { value: '中文主题说明。' } })
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
+ locale.setLocale('en')
+ view.rerender( )
+ expect(screen.getByText(en.emptySearch)).toBeTruthy()
+ fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '@acme/dsh-theme/client' } })
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
+ expect(screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' }).getAttribute('aria-describedby')).toBeNull()
+ expect(list).toHaveBeenCalledOnce()
+ })
+
+ it('keeps metadata-error-only rows inspectable by their full module specifier', async () => {
+ const error = 'locale/zh.json: invalid title'
+ await renderReady({
+ entries: [{
+ entryId: 'include:legacy' as PluginEntryId, moduleName: '@acme/dsh-legacy', enabled: false, fiberPhase: null,
+ meta: { error },
+ }],
+ })
+ fireEvent.click(globalToggle())
+ expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
+ const card = screen.getByRole('button', { name: 'legacy, include:legacy, Disabled' })
+ expect(card).toHaveProperty('disabled', false)
+ expect(card.closest('li')?.getAttribute('data-failed')).toBeNull()
+ fireEvent.click(card)
+ expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-legacy')
+ })
+
it('shows the default preset first with both groups collapsed', async () => {
const view = await renderReady()
diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json
index 1a8569048d..491b34792f 100644
--- a/packages/client/ui-settings-plugin-inventory/tsconfig.json
+++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/package-manifest"
+ },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/experimental/agent-team-profile/locale/en.json b/packages/experimental/agent-team-profile/locale/en.json
new file mode 100644
index 0000000000..b69c01b6c7
--- /dev/null
+++ b/packages/experimental/agent-team-profile/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Agent Teams",
+ "description": "Enable team collaboration, team tools, the member roster, and the shared task board."
+ }
+}
diff --git a/packages/experimental/agent-team-profile/locale/zh.json b/packages/experimental/agent-team-profile/locale/zh.json
new file mode 100644
index 0000000000..4943f297e7
--- /dev/null
+++ b/packages/experimental/agent-team-profile/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "智能体团队",
+ "description": "启用团队协作、团队工具、成员列表和共享任务看板。"
+ }
+}
diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json
index a8b8967147..73999ee5d4 100644
--- a/packages/experimental/agent-team-profile/package.json
+++ b/packages/experimental/agent-team-profile/package.json
@@ -19,10 +19,12 @@
"default": "./lib/index.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
+ "./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
+ "locale/*.json",
"lib/index.js",
"cordis.patch.yml",
"lib/types/**/*.d.ts"
diff --git a/packages/experimental/agent-team/locale/en.json b/packages/experimental/agent-team/locale/en.json
new file mode 100644
index 0000000000..5d93c64811
--- /dev/null
+++ b/packages/experimental/agent-team/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Team Collaboration",
+ "description": "Manage team members, member messages, and shared tasks."
+ }
+}
diff --git a/packages/experimental/agent-team/locale/zh.json b/packages/experimental/agent-team/locale/zh.json
new file mode 100644
index 0000000000..5c27bbbf75
--- /dev/null
+++ b/packages/experimental/agent-team/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "团队协作",
+ "description": "管理团队成员、成员消息和共享任务。"
+ }
+}
diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json
index 8a1ebd621d..db127f9f7e 100644
--- a/packages/experimental/agent-team/package.json
+++ b/packages/experimental/agent-team/package.json
@@ -38,10 +38,12 @@
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
+ "./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
+ "locale/*.json",
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
diff --git a/packages/experimental/auto-review/locale/en.json b/packages/experimental/auto-review/locale/en.json
new file mode 100644
index 0000000000..54c040dfcd
--- /dev/null
+++ b/packages/experimental/auto-review/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Auto Authorization Review",
+ "description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call."
+ }
+}
diff --git a/packages/experimental/auto-review/locale/zh.json b/packages/experimental/auto-review/locale/zh.json
new file mode 100644
index 0000000000..6a9005d0a1
--- /dev/null
+++ b/packages/experimental/auto-review/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "自动授权审查",
+ "description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。"
+ }
+}
diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json
index 853cc277c6..f75c46c8da 100644
--- a/packages/experimental/auto-review/package.json
+++ b/packages/experimental/auto-review/package.json
@@ -15,11 +15,13 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json",
"./cordis.patch.yml": "./cordis.patch.yml"
},
"files": [
+ "locale/*.json",
"lib/index.js",
"cordis.patch.yml",
"lib/types/**/*.d.ts"
diff --git a/packages/experimental/client-ui-agent-team/locale/en.json b/packages/experimental/client-ui-agent-team/locale/en.json
new file mode 100644
index 0000000000..ad6f1665e5
--- /dev/null
+++ b/packages/experimental/client-ui-agent-team/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Team Panel",
+ "description": "View team members and shared tasks, and navigate to member conversations."
+ }
+}
diff --git a/packages/experimental/client-ui-agent-team/locale/zh.json b/packages/experimental/client-ui-agent-team/locale/zh.json
new file mode 100644
index 0000000000..383f0dfec9
--- /dev/null
+++ b/packages/experimental/client-ui-agent-team/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "团队面板",
+ "description": "查看团队成员和共享任务,并切换到成员会话。"
+ }
+}
diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json
index b344f4cfe0..c980e32c16 100644
--- a/packages/experimental/client-ui-agent-team/package.json
+++ b/packages/experimental/client-ui-agent-team/package.json
@@ -22,6 +22,7 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
+ "./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -79,6 +80,7 @@
"react-dom": "^18.2.0"
},
"files": [
+ "locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/types/**/*.d.ts"
diff --git a/packages/experimental/tool-agent-team/locale/en.json b/packages/experimental/tool-agent-team/locale/en.json
new file mode 100644
index 0000000000..f517900c98
--- /dev/null
+++ b/packages/experimental/tool-agent-team/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Team Tools",
+ "description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks."
+ }
+}
diff --git a/packages/experimental/tool-agent-team/locale/zh.json b/packages/experimental/tool-agent-team/locale/zh.json
new file mode 100644
index 0000000000..fee0b1da5e
--- /dev/null
+++ b/packages/experimental/tool-agent-team/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "团队工具",
+ "description": "为智能体提供成员协调、消息通信和共享任务管理工具。"
+ }
+}
diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json
index f638578aac..f1436528f3 100644
--- a/packages/experimental/tool-agent-team/package.json
+++ b/packages/experimental/tool-agent-team/package.json
@@ -18,10 +18,12 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
+ "locale/*.json",
"lib/index.js",
"lib/types/**/*.d.ts"
],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 01edd8cb6a..ba8a30e914 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2471,6 +2471,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
+ '@deepseek-ai/dsh-package-manifest':
+ specifier: workspace:^
+ version: link:../../util/package-manifest
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
@@ -3767,6 +3770,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
+ '@deepseek-ai/dsh-package-manifest':
+ specifier: workspace:^
+ version: link:../../util/package-manifest
'@deepseek-ai/dsh-plugin-manager':
specifier: workspace:^
version: link:../../boot/plugin-manager
@@ -4210,6 +4216,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
+ '@deepseek-ai/dsh-package-manifest':
+ specifier: workspace:^
+ version: link:../../util/package-manifest
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)