Merge branch 'worktree/input-plugin-extension-hooks' into worktree/experimental-voice-input

# Conflicts:
#	apps/desktop/README.i18n.yaml
#	apps/desktop/README.md
#	apps/desktop/README.zh.md
#	packages/client/ui-plugin-manager/src/client/presentation.ts
#	packages/client/ui-plugin-manager/tests/components.client.spec.tsx
#	packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
This commit is contained in:
Yichen Jiang committed 2026-09-21 11:15:10 +08:00
commit 103aee7396
16 files changed
+163 -17

No files matched your search

+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 26a738a37a04eac2842b804853066d10f1834739
README.zh.md: 870aeae036665dac19a7e7d359dc40d03ec20faf
README.md: 0c600aee8a4a0141eb46e0850d0283a9997d7115
README.zh.md: 495b5e10ddb5d6a2ec1174e0217f1b94ba00eaab
+1 -1
View File
@@ -10,7 +10,7 @@ Desktop’s local native directory flow opens an Electron folder dialog attached
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. Native speech libraries remain outside ASAR and use the existing runtime signing path; recognition runs in a managed Node-mode child.
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description.
## Key technical decisions
+1 -1
View File
@@ -10,7 +10,7 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。原生语音库保留在 ASAR 外并使用既有运行时签名路径;识别在托管的 Node 模式子进程中执行。
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。
## 关键技术决策
@@ -0,0 +1,5 @@
- textbox "Describe what you want to build, / commands, @ files or sessions":
- paragraph: Draft
- button "Cancel activity"
- button "Insert result"
- button "Send message"
@@ -0,0 +1,46 @@
/** A published-protocol consumer of composer activities and bundle activation guidance. */
window.__ModuleLoader__.load({
id: '@fixture/input-extension',
factory(require) {
const React = require('react')
const { Button, Modal, IconApiOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
return {
inject: ['slots', 'locale'],
apply(ctx) {
const labels = {
start: 'Input activity', cancel: 'Cancel activity', insert: 'Insert result', stale: 'Draft changed',
setup: 'Input plugin setup', details: 'Open setup', later: 'Later', config: 'Input plugin configuration',
}
ctx.effect(() => ctx.locale.register('fixtureInput', { en: labels, zh: labels }))
ctx.slots.inject('conversation.input.activity', () => ctx.slots.register({
name: 'conversation.input.activity', locale: 'fixtureInput',
}, ({ inputActions, onActiveChange, locked, t }) => {
const [span, setSpan] = React.useState(null)
const [stale, setStale] = React.useState(false)
React.useEffect(() => () => onActiveChange(false), [onActiveChange])
const close = () => { setSpan(null); setStale(false); onActiveChange(false) }
return span === null
? h(Button, { disabled: locked, 'aria-label': t('start'), style: { width: 32, height: 32, padding: 0 }, onClick: () => {
setSpan(inputActions.captureInsertion()); onActiveChange(true)
} }, h(IconApiOutlineRegular, { size: 16 }))
: h('div', null,
h(Button, { onClick: close }, t('cancel')),
h(Button, { onClick: () => {
if (inputActions.insertText(' from plugin', span)) close()
else setStale(true)
} }, t('insert')),
stale ? h('span', { role: 'status' }, t('stale')) : null)
}))
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
name: 'plugins.bundle.config', key: '@fixture/input-extension', locale: 'fixtureInput',
}, ({ t }) => h('p', null, t('config'))))
ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({
name: 'plugins.bundle.activation', key: '@fixture/input-extension', locale: 'fixtureInput',
}, ({ onDismiss, onOpenDetails, t }) => h(Modal, {
open: true, title: t('setup'), closeLabel: t('later'), onClose: onDismiss,
}, h(Button, { onClick: onDismiss }, t('later')), h(Button, { onClick: onOpenDetails }, t('details')))))
},
}
},
})
@@ -0,0 +1,3 @@
- insert:
- id: fixture-input-extension
name: '@fixture/input-extension'
@@ -0,0 +1,2 @@
/** Host companion for the optional input extension fixture. */
export function apply() {}
@@ -0,0 +1,12 @@
{
"name": "@fixture/input-extension",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
}
@@ -0,0 +1,82 @@
/** Optional input contributions mount through the real Loader and preserve editor ownership. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const bundle = fileURLToPath(new URL('./fixtures/plugins/fixture-input-extension', import.meta.url))
const expected = fileURLToPath(new URL('./expected/input-plugin-extensions.expected.md', import.meta.url))
async function fixture(enabled: boolean) {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
})
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled }] } })
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
return { scaffold, page, tripwire }
}
it('expands an input activity and inserts one undoable result without overwriting subsequent edits', async () => {
const { scaffold, page, tripwire } = await fixture(true)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]')
const start = page.getByRole('button', { name: 'Input activity', exact: true })
const model = page.getByRole('button', { name: /^Select model, current/ })
const send = page.getByRole('button', { name: 'Send message', exact: true })
await input.fill('Draft')
await input.press('End')
for (const width of [1280, 420]) {
await page.setViewportSize({ width, height: 900 })
await expect.poll(async () => {
const activityBox = await start.boundingBox(), modelBox = await model.boundingBox(), sendBox = await send.boundingBox()
return activityBox !== null && modelBox !== null && sendBox !== null
&& activityBox.x >= modelBox.x + modelBox.width && activityBox.x + activityBox.width <= sendBox.x
}).toBe(true)
}
await start.click()
expect(await model.isVisible()).toBe(false)
expect(await send.isVisible()).toBe(true)
await compareOrRefreshGolden(expected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
await page.getByRole('button', { name: 'Insert result', exact: true }).click()
await expect.poll(() => input.innerText()).toBe('Draft from plugin')
await input.press('ControlOrMeta+z')
await expect.poll(() => input.innerText()).toBe('Draft')
await start.click()
await input.fill('Newer draft')
await page.getByRole('button', { name: 'Insert result', exact: true }).click()
await page.getByRole('status').filter({ hasText: 'Draft changed' }).waitFor()
expect(await input.innerText()).toBe('Newer draft')
await page.getByRole('button', { name: 'Cancel activity', exact: true }).click()
expect(await model.isVisible()).toBe(true)
expect(tripwire.pageErrors).toEqual([])
})
it('offers bundle guidance only after explicit activation and navigates to its configuration', async () => {
const { page, tripwire } = await fixture(false)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
const toggle = page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true })
await toggle.waitFor()
const dialog = page.getByRole('dialog', { name: 'Input plugin setup', exact: true })
expect(await dialog.count()).toBe(0)
await toggle.click()
await dialog.waitFor()
await dialog.getByRole('button', { name: 'Later', exact: true }).last().click()
await dialog.waitFor({ state: 'hidden' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
await toggle.click()
await page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true, checked: false }).waitFor()
await toggle.click()
await dialog.getByRole('button', { name: 'Open setup', exact: true }).click()
await page.getByText('Input plugin configuration', { exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
})
+1
View File
@@ -53,6 +53,7 @@
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/plugin-install-cancel.e2e.ts",
"tests/plugin-install-approve.e2e.ts",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
README.md: bc26ba0fce842adc103a0f04a8640dfd504dd8e1
README.zh.md: ce8c93aa22a32f874731e6fafa7be6445a5c45b5
README.md: 75d972a310f7f5fc505775bcc72d2f18675d0c89
README.zh.md: 66c441950ba288f554e2e41750cfe4e0c0b0b359
+1 -1
View File
@@ -77,7 +77,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
### Configuration slots
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package.
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Beta marker.
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
@@ -77,7 +77,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
### 配置 slot
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示 Beta 标记。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
@@ -8,13 +8,6 @@ import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManage
/** The translate seat of the manager's dictionary. */
export type Translate = PropsLocale<'pluginManager'>['t']
/** 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',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
])
/** The registries with a name of their own, by host. */
const REGISTRY_COPY = new Map<string, PluginManagerLocaleKey>([
['registry.npmmirror.com', 'registryNpmmirror'],
@@ -106,7 +99,7 @@ export function packageText(
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),
beta: pkg.name.startsWith('@deepseek-ai/dsh-experimental-'),
}
}
@@ -258,7 +258,7 @@ describe('PluginManagerPage', () => {
it.each([
'@deepseek-ai/dsh-experimental-agent-team-profile',
'@deepseek-ai/dsh-experimental-auto-review',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-fixture-input',
'@acme/dsh-local-tools',
])('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name) => {
const meta = {
@@ -275,6 +275,7 @@ describe('PluginManagerPage', () => {
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()
expect(screen.queryByText(dict.statusBeta) !== null).toBe(name.startsWith('@deepseek-ai/dsh-experimental-'))
}
assertCard(en)
setLanguage(zh)
+1
View File
@@ -51,6 +51,7 @@
"apps/web/tests/office-fixture.ts",
"apps/web/tests/sidebar-browser.e2e.ts",
"apps/web/tests/plugin-config.e2e.ts",
"apps/web/tests/input-plugin-extensions.e2e.ts",
"apps/web/tests/plugin-manager.e2e.ts",
"apps/web/tests/plugin-install-cancel.e2e.ts",
"apps/web/tests/plugin-install-approve.e2e.ts",