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:
commit
103aee7396
16 files changed
+163
-17
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user