feat(web): display plugin-owned locale metadata

This commit is contained in:
imccyu committed 2026-09-19 20:19:04 +08:00
1 parent 0b7cb4102b
commit 0d611ea151
59 files changed
+1067 -200

No files matched your search

@@ -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"
@@ -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: 示例配置项
@@ -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"
@@ -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: 已关闭
@@ -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]
@@ -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"
@@ -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: 这个插件包不包含任何组件。
@@ -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
@@ -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
@@ -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'
@@ -0,0 +1,3 @@
{
"meta": {}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "代码审查",
"description": "审查工作区中的改动。"
}
}
@@ -0,0 +1,5 @@
{
"meta": {
"title": "File Search"
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "文件搜索",
"description": "搜索工作区中的文件。"
}
}
+10 -1
View File
@@ -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"
@@ -0,0 +1,2 @@
/** Inert review plugin for exported metadata browser coverage. */
export function apply() {}
@@ -0,0 +1,2 @@
/** Inert search plugin for exported metadata browser coverage. */
export function apply() {}
@@ -0,0 +1,4 @@
{
"name": "Search plugin",
"description": "Search package introduction."
}
+7 -6
View File
@@ -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)
+78 -28
View File
@@ -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<void> {
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()
+4 -1
View File
@@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => {
let tripwire: ReturnType<typeof watchConsole>
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).
+1
View File
@@ -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:^",
@@ -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).
@@ -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')
})
})
+3
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/package-manifest"
},
{
"path": "../../../vendor/schemastery"
},
@@ -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",
@@ -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 (
<Switch
checked={row.enabled}
label={t('partToggle', { name: row.rowId })}
label={t('partToggle', { name: title })}
disabled={busy || locked}
{...row.readOnlyReason === undefined ? {} : { title: managementText({ code: row.readOnlyReason }, t) }}
onChange={onChange}
@@ -177,21 +179,29 @@ function rowDotState(row: PackageRow): StateDotState {
return PHASE_STATES[row.phase]
}
/** A Host metadata diagnostic does not change the package's management permissions. */
function MetadataError({ error, t }: { readonly error: string | undefined; readonly t: Translate }): ReactNode {
return error === undefined ? null : <p className={css.reason} role="status" data-package-meta-error>{t('metadataError', { error })}</p>
}
/**
* 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 (
<section className={css.detailSection} data-plugin-rows>
<div className={css.sectionHead}>
@@ -216,7 +226,7 @@ function RowsSection({ rows, t, toggle, configure }: {
? null
: (
<ul className={css.rows}>
{shown.map(row => (
{shown.map(({ row, title, description }) => (
<li
key={row.rowId}
className={css.row}
@@ -228,13 +238,15 @@ function RowsSection({ rows, t, toggle, configure }: {
<div className={css.rowMain}>
{configure?.has(row) === true
? (
<button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: row.rowId })} onClick={() => { configure.open(row) }}>
<span className={css.rowId}>{row.rowId}</span>
<button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: title })} onClick={() => { configure.open(row) }}>
<span className={css.rowId}>{title}</span>
<IconChevronRightOutlineRegular className={css.rowOpenIcon} aria-hidden="true" />
</button>
)
: <span className={css.rowId}>{row.rowId}</span>}
<span className={css.rowModule}>{row.moduleName}</span>
: <span className={css.rowId}>{title}</span>}
{description === undefined ? null : <span className={css.rowModule}>{description}</span>}
{title === row.rowId ? null : <code className={css.rowModule}>{row.rowId}</code>}
{title === row.moduleName ? null : <code className={css.rowModule}>{row.moduleName}</code>}
</div>
<span className={css.rowState}>
<StateDot state={rowDotState(row)} />
@@ -242,8 +254,12 @@ function RowsSection({ rows, t, toggle, configure }: {
</span>
{toggle === undefined
? null
: <RowSwitch row={row} t={t} busy={toggle.busy(row)} onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }} />}
: <RowSwitch
row={row} title={title} t={t} busy={toggle.busy(row)}
onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }}
/>}
</div>
<MetadataError error={row.meta?.error} t={t} />
</li>
))}
</ul>
@@ -290,15 +306,16 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: {
readonly description: ReactNode
readonly end?: ReactNode
}): ReactNode {
const descriptionId = useId()
return (
<div className={css.cardHead}>
<span className={css.cardIcon} aria-hidden="true">{icon}</span>
<div className={css.cardMain}>
<div className={css.titleRow}>
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} aria-describedby={description === undefined ? undefined : descriptionId} onClick={onOpen}>{title}</button>
{tags}
</div>
{description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
{description === undefined ? null : <span className={css.cardDesc} id={descriptionId}>{description}</span>}
</div>
{end === undefined ? null : <div className={css.cardEnd}>{end}</div>}
</div>
@@ -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 (
<li
@@ -359,6 +377,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
description={description}
end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
/>
<MetadataError error={pkg.meta?.error} t={t} />
</li>
)
}
@@ -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 (
<div className={css.detail} data-plugin-row-detail={key}>
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<PluginArtworkSubagent size={CARD_ARTWORK_SIZE} />} />
<div className={css.detailMain}>
<div className={css.titleRow}>
<h3 className={css.detailTitle}>{row.rowId}</h3>
<h3 className={css.detailTitle}>{rowTitle}</h3>
</div>
{rowTitle === row.rowId ? null : <p className={css.detailName}><code>{row.rowId}</code></p>}
<p className={css.detailName}><code>{row.moduleName}</code></p>
<p className={css.detailDesc}>{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
<p className={css.detailDesc}>{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
</div>
<MetadataError error={row.meta?.error} t={t} />
<div className={css.detailSections} data-plugin-config>
{renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
</div>
@@ -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 (
<div className={css.detail} data-plugin-detail={pkg.name}>
@@ -497,8 +521,9 @@ function PackageDetail({
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
</div>
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
<p className={css.detailDesc}>{description ?? t('noDescription')}</p>
{description === undefined ? null : <p className={css.detailDesc}>{description}</p>}
</div>
<MetadataError error={pkg.meta?.error} t={t} />
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
<div className={css.detailSections}>
@@ -512,6 +537,7 @@ function PackageDetail({
<RowsSection
rows={pkg.rows}
t={t}
resolveText={resolveText}
toggle={pkg.enabled ? { busy: row => 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 (
<Modal
open
@@ -1040,7 +1065,7 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
/** Render the plugin manager: the official plugins and installed bundles, their pages, the install dialog, and the confirmation. */
export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
const { t, ensure, renderSlot } = props
const { t, ensure, renderSlot, resolveText } = props
const state = props.usePluginManager(snapshot => 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 {
<PackageDetail
pkg={openPkg}
t={t}
resolveText={resolveText}
busy={state.busy.includes(openPkg.name)}
rowBusy={row => 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
: (
<ConfirmDialog
confirm={state.confirm}
name={packageText(
state.packages.find(pkg => pkg.name === state.confirm?.packageName) ?? { name: state.confirm.packageName },
resolveText,
).title}
t={t}
onConfirm={props.confirm}
onCancel={props.cancelConfirm}
@@ -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' },
@@ -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',
@@ -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<PluginManagerState>
@@ -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<ConfigLedger>): PluginManagerFace {
inject(configLedger: HostObservable<ConfigLedger>, resolveText: PluginManagerFace['resolveText']): PluginManagerFace {
return {
resolveText,
hooks: { pluginManager: this.store, configLedger },
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
refresh: () => { void this.load() },
@@ -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<string, { title: PluginManagerLocaleKey; description: PluginManagerLocaleKey; beta: boolean }>([
['@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<PackageView, 'name' | 'description'>, t: Translate,
pkg: Pick<PackageView, 'name' | 'meta'>, 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<PackageRow, 'moduleName' | 'meta'>, 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,
}
}
/**
@@ -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
* `<package name>#<row id>` 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 }
}
@@ -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' }])
@@ -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<string, (view: 'summary' | 'page') => ReactNode>
const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() }
function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<ConfigLedger> = {}, 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<PluginManagerState>({ ...READY, ...state })
const ledger = createSnapshotStore<ConfigLedger>({ ...NO_CONFIG, ...config })
const actions = {
@@ -96,6 +103,7 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
}
const props = {
t,
resolveText,
...actions,
usePluginManager: bindSnapshotSelector(store),
useConfigLedger: bindSnapshotSelector(ledger),
@@ -107,7 +115,10 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
store,
actions,
set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
setLanguage: (dict: typeof en) => { rerender(<PluginManagerPage {...props} t={translate(dict)} />) },
setLanguage: (dict: typeof en) => {
locale.setLocale(dict === zh ? 'zh' : 'en')
rerender(<PluginManagerPage {...props} t={translate(dict)} />)
},
}
}
@@ -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<PackageView['meta']>
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' ? <form aria-label="theme settings" /> : 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' ? <form aria-label="sidebar settings" /> : 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' : <form aria-label="row settings" /> },
)
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.' : <form aria-label="bash form" />,
@@ -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<HTMLElement>(`[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)
})
@@ -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<Record<string, ReturnType<typeof vi.fn>>> = {}
}
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<PluginInstallRequestId> => {
@@ -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<ReturnType<typeof ok<BundleInfo[]>>>()
const { plugins, inventory, face, state, controller } = bench({
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/package-manifest"
},
{
"path": "../../../vendor/cordis"
},
@@ -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:^",
@@ -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<ClientEntryState> }
/** 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 (
<li
className={css.card}
@@ -122,17 +141,20 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed,
aria-expanded={open}
aria-controls={detailId}
aria-label={ariaLabel}
aria-describedby={description === undefined ? undefined : descriptionId}
onClick={() => { onToggle(rowKey) }}
>
<span className={css.cardMainRow}>
<strong className={css.cardTitle} title={moduleName}>{moduleShortName(moduleName)}</strong>
<strong className={css.cardTitle} title={moduleName}>{title}</strong>
<span className={css.cardTrailing}>
{trailing}
<IconChevronDownOutlineRegular className={css.chevron} size={12} aria-hidden="true" />
</span>
</span>
{description === undefined ? null : <span className={css.hint} id={descriptionId}>{description}</span>}
{entryId === null ? null : <code className={css.cardIdentity} title={entryId}>{entrySubtitle(entryId)}</code>}
</button>
{metadataError === undefined ? null : <p className={css.brokenNote} role="status" data-package-meta-error>{metadataError}</p>}
{open ? <div className={css.cardDetails} id={detailId}>{children}</div> : null}
</li>
)
@@ -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}
@@ -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) }) },
})
@@ -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.',
@@ -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'))
@@ -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<string, string>): 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<ClientEntryState>({ 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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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(<PluginInventorySettingsTab {...pageProps} />)
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()
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/package-manifest"
},
{
"path": "../../../vendor/cordis"
},
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Agent Teams",
"description": "Enable team collaboration, team tools, the member roster, and the shared task board."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "智能体团队",
"description": "启用团队协作、团队工具、成员列表和共享任务看板。"
}
}
@@ -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"
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Team Collaboration",
"description": "Manage team members, member messages, and shared tasks."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "团队协作",
"description": "管理团队成员、成员消息和共享任务。"
}
}
@@ -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",
@@ -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."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "自动授权审查",
"description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。"
}
}
@@ -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"
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Team Panel",
"description": "View team members and shared tasks, and navigate to member conversations."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "团队面板",
"description": "查看团队成员和共享任务,并切换到成员会话。"
}
}
@@ -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"
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Team Tools",
"description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "团队工具",
"description": "为智能体提供成员协调、消息通信和共享任务管理工具。"
}
}
@@ -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"
],
+9
View File
@@ -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/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])