From 0aa6f94749dc387f1ad930c8bdb77e84e5c857e7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 21 Sep 2026 11:11:21 +0800 Subject: [PATCH 1/2] feat(web): expose input plugin extension hooks --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/electron-builder.config.d.mts | 1 + .../scripts/electron-builder-config.mjs | 1 + apps/desktop/src/main.ts | 2 + apps/desktop/src/microphone-permissions.ts | 31 +++++++ apps/desktop/tests/macos-signature.spec.ts | 1 + apps/desktop/tests/main-startup.spec.ts | 4 +- .../tests/microphone-permissions.spec.ts | 69 ++++++++++++++ .../input-plugin-extensions.expected.md | 5 + .../plugins/fixture-input-extension/client.js | 46 ++++++++++ .../fixture-input-extension/cordis.patch.yml | 3 + .../plugins/fixture-input-extension/index.js | 2 + .../fixture-input-extension/package.json | 12 +++ apps/web/tests/input-plugin-extensions.e2e.ts | 82 +++++++++++++++++ apps/web/tests/scaffold.ts | 6 +- apps/web/tsconfig.json | 1 + docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 + docs/subsystems/slots.zh.md | 2 + .../tests/message-image.client.spec.tsx | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 2 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 + packages/client/ui-conversation/README.zh.md | 4 + .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/input.ts | 9 ++ .../src/client/contract/slots.ts | 10 +- .../src/client/input/editor/runtime.ts | 13 +++ .../src/client/input/facade.ts | 6 ++ .../src/client/skeleton/InputBar.module.css | 7 ++ .../src/client/skeleton/InputBar.tsx | 21 +++-- .../tests/async-insertion.client.spec.ts | 38 ++++++++ .../tests/input-bar.client.spec.tsx | 48 +++++++++- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 4 +- .../client/ui-plugin-manager/README.zh.md | 4 +- .../src/client/PluginManagerPage.tsx | 16 +++- .../ui-plugin-manager/src/client/index.ts | 3 +- .../src/client/presentation.ts | 8 +- .../src/client/slot-contract.ts | 11 +++ .../tests/components.client.spec.tsx | 46 +++++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/StateDot.module.css | 5 + .../client/ui-primitives/src/StateDot.tsx | 9 +- .../client/ui-primitives/src/icons/index.tsx | 16 ++++ .../ui-primitives/tests/icons.client.spec.tsx | 5 +- .../tests/state-dot.client.spec.tsx | 8 ++ .../tests/conversation-ui.client.spec.tsx | 2 + .../ui-trajectory/tests/views.client.spec.tsx | 4 + .../tests/plan-review-panel.client.spec.tsx | 2 + .../user-questions-composer.client.spec.tsx | 2 + .../tests/workflow-run.client.spec.tsx | 2 + .../src/client/slot-catalog.ts | 91 +++++++++++++++++-- tsconfig.host.json | 1 + 58 files changed, 646 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/microphone-permissions.ts create mode 100644 apps/desktop/tests/microphone-permissions.spec.ts create mode 100644 apps/web/tests/expected/input-plugin-extensions.expected.md create mode 100644 apps/web/tests/fixtures/plugins/fixture-input-extension/client.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml create mode 100644 apps/web/tests/fixtures/plugins/fixture-input-extension/index.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-input-extension/package.json create mode 100644 apps/web/tests/input-plugin-extensions.e2e.ts create mode 100644 packages/client/ui-conversation/tests/async-insertion.client.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index b536269a93..0dae249c08 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: 48ad6bbcf515aac5734da1fd406ce70f0f4d6505 -README.zh.md: 795e62a0e119e40211387a12f9e4fa56fdde3e79 +README.md: 0c600aee8a4a0141eb46e0850d0283a9997d7115 +README.zh.md: 495b5e10ddb5d6a2ec1174e0217f1b94ba00eaab diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 48ad6bbcf5..0c600aee8a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,8 @@ 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. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 795e62a0e1..495b5e10dd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,6 +10,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index f7cfd63f42..632250119b 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -21,6 +21,7 @@ export interface DesktopElectronBuilderConfig { { readonly from: string, readonly to: 'icon.png' }, ] readonly mac: { + readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index dd4b00d24c..3f469c3be2 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -131,6 +131,7 @@ export function createElectronBuilderConfig( identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' }, // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], notarize: true, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7531a7cfa9..5629582dae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' +import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' @@ -426,6 +427,7 @@ async function main(): Promise { }) installDesktopDirectoryPicker(() => mainWindow) + installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) diff --git a/apps/desktop/src/microphone-permissions.ts b/apps/desktop/src/microphone-permissions.ts new file mode 100644 index 0000000000..4d41a8e390 --- /dev/null +++ b/apps/desktop/src/microphone-permissions.ts @@ -0,0 +1,31 @@ +/** Microphone access belongs to the primary application frame and the operating system. */ +import { systemPreferences, type Session, type WebContents } from 'electron' + +function applicationFrame(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'dsh-app:' && parsed.hostname === 'app' + } catch (_error) { return false /* Invalid frame URLs cannot request microphone access. */ } +} + +/** + * Handle microphone requests from the owned application window; other permissions retain Electron's defaults. + * @param session - application's browser session. + * @param primary - current primary window contents, absent while no window is open. + */ +export function installMicrophonePermissions(session: Pick, primary: () => WebContents | undefined): void { + session.setPermissionCheckHandler((contents, permission, origin, details) => { + if (permission !== 'media') return true + return contents != null && contents === primary() && details.isMainFrame + && applicationFrame(origin) && details.mediaType === 'audio' + && (process.platform !== 'darwin' || systemPreferences.getMediaAccessStatus('microphone') === 'granted') + }) + session.setPermissionRequestHandler((contents, permission, callback, details) => { + if (permission !== 'media') { callback(true); return } + const allowed = contents === primary() && details.isMainFrame && applicationFrame(details.requestingUrl) + && 'mediaTypes' in details && details.mediaTypes.length === 1 && details.mediaTypes[0] === 'audio' + if (!allowed) { callback(false); return } + if (process.platform !== 'darwin') { callback(true); return } + void systemPreferences.askForMediaAccess('microphone').then(callback, () => { callback(false) }) + }) +} diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 4d71d3048b..6cd8faeccc 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -42,6 +42,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') + expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone') expect(config.extraResources).toHaveLength(2) expect(config.extraResources[0]?.to).toBe('runtime') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 8547bc28fe..9be17e5321 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -211,7 +211,9 @@ vi.mock('electron', () => ({ removeHandler: (channel: string) => { harness.handlers.delete(channel) }, }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, - session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, + session: { defaultSession: { + setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, + } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, })) diff --git a/apps/desktop/tests/microphone-permissions.spec.ts b/apps/desktop/tests/microphone-permissions.spec.ts new file mode 100644 index 0000000000..200a980f20 --- /dev/null +++ b/apps/desktop/tests/microphone-permissions.spec.ts @@ -0,0 +1,69 @@ +/** Desktop media permissions exclude external pages, subframes and camera requests. */ +import type { Session, WebContents } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installMicrophonePermissions } from '../src/microphone-permissions.ts' + +const access = vi.hoisted(() => ({ getMediaAccessStatus: vi.fn(() => 'granted'), askForMediaAccess: vi.fn(async () => true) })) +vi.mock('electron', () => ({ systemPreferences: access })) +afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks() }) +function fixture(platform: NodeJS.Platform = 'darwin') { + vi.stubGlobal('process', { ...process, platform }) + const setPermissionCheckHandler = vi.fn() + const setPermissionRequestHandler = vi.fn() + const primary = {} as WebContents + installMicrophonePermissions({ setPermissionCheckHandler, setPermissionRequestHandler }, () => primary) + return { primary, check: setPermissionCheckHandler.mock.calls[0]![0]!, request: setPermissionRequestHandler.mock.calls[0]![0]! } +} +it('allows only audio checks from the owned primary application frame', () => { + const f = fixture(), details = { isMainFrame: true, mediaType: 'audio' as const } + expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(true) + expect(f.check(null, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check({} as WebContents, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check(f.primary, 'media', 'https://example.com', details)).toBe(false) + expect(f.check(f.primary, 'media', 'invalid', details)).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, isMainFrame: false })).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, mediaType: 'video' })).toBe(false) + expect(f.check(f.primary, 'clipboard-sanitized-write', 'dsh-app://app', details)).toBe(true) +}) +it.each(['not-determined', 'granted', 'denied', 'restricted', 'unknown'] as const)( + 'reports existing macOS microphone authorization for %s', (status) => { + const f = fixture() + access.getMediaAccessStatus.mockReturnValueOnce(status) + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(status === 'granted') + expect(access.getMediaAccessStatus).toHaveBeenCalledExactlyOnceWith('microphone') + expect(access.askForMediaAccess).not.toHaveBeenCalled() + }, +) +it.each([true, false])('waits for the first macOS authorization decision: %s', async (allowed) => { + const f = fixture(), decision = Promise.withResolvers(), done = vi.fn() + access.getMediaAccessStatus.mockReturnValue('not-determined') + access.askForMediaAccess.mockReturnValueOnce(decision.promise) + try { + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(access.askForMediaAccess).toHaveBeenCalledExactlyOnceWith('microphone') + expect(done).not.toHaveBeenCalled() + } finally { decision.resolve(allowed); await decision.promise } + expect(done).toHaveBeenCalledExactlyOnceWith(allowed) +}) +it('requests macOS microphone access and reports operating-system rejection', async () => { + const f = fixture(), done = vi.fn(), details = { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] } + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(true) }) + expect(access.askForMediaAccess).toHaveBeenCalledWith('microphone') + access.askForMediaAccess.mockRejectedValueOnce(new Error('permission denied')) + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(false) }) + for (const denied of [{ ...details, mediaTypes: ['audio', 'video'] }, { ...details, mediaTypes: [] }, + { ...details, isMainFrame: false }, { isMainFrame: true, requestingUrl: 'dsh-app://app/' }]) { + f.request(f.primary, 'media', done, denied); expect(done).toHaveBeenLastCalledWith(false) + } + f.request(f.primary, 'clipboard-sanitized-write', done, details) + expect(done).toHaveBeenLastCalledWith(true) +}) +it.each(['win32', 'linux'] as const)('leaves system authorization to Chromium on %s', (platform) => { + const f = fixture(platform), done = vi.fn() + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(true) + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(done).toHaveBeenCalledWith(true) + expect(access.askForMediaAccess).not.toHaveBeenCalled() +}) diff --git a/apps/web/tests/expected/input-plugin-extensions.expected.md b/apps/web/tests/expected/input-plugin-extensions.expected.md new file mode 100644 index 0000000000..512b4d0884 --- /dev/null +++ b/apps/web/tests/expected/input-plugin-extensions.expected.md @@ -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" diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js new file mode 100644 index 0000000000..6722f15db8 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js @@ -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'))))) + }, + } + }, +}) diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml new file mode 100644 index 0000000000..b31d3a8fb7 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: fixture-input-extension + name: '@fixture/input-extension' diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js new file mode 100644 index 0000000000..7f53b13ca2 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js @@ -0,0 +1,2 @@ +/** Host companion for the optional input extension fixture. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json new file mode 100644 index 0000000000..5590f85115 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json @@ -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" } + } +} diff --git a/apps/web/tests/input-plugin-extensions.e2e.ts b/apps/web/tests/input-plugin-extensions.e2e.ts new file mode 100644 index 0000000000..cd8548c6f3 --- /dev/null +++ b/apps/web/tests/input-plugin-extensions.e2e.ts @@ -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([]) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 7cf05dd30a..90f6387c7d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -305,8 +305,8 @@ export interface LaunchOptions { developerTools?: boolean /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot - /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ - compareReplaySession?: boolean + /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ + compareReplaySession?: boolean | 'read-only' /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -965,7 +965,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { useTrajectory, useInput, inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: vi.fn(), addAttachments: vi.fn(() => true), removeAttachment: vi.fn(), diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1ee50e0265..b07f0b5748 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -395,6 +395,8 @@ function makeHarness( }, useInput: (() => { throw new Error('unused') }), inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..84c42ef4a7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 5e42c1e11bd33f764ae85ef029a127f99541fc71 +README.zh.md: 3827193e703e19df142e3526cc2bbc8b8e5eb57a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..5e42c1e11b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -120,6 +120,10 @@ try { The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. +`InputActions.captureInsertion()` captures the draft selection and revision; `insertText(text, span)` inserts one undoable plain-text edit only while that revision is current and the editor permits editing. Asynchronous consumers retain rejected results for user action. + +`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls. The occupant releases expansion on unmount and owns any activity-specific feedback. + ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..3827193e70 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -120,6 +120,10 @@ try { selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core 包声明。 +`InputActions.captureInsertion()` 捕获草稿选区与版本;`insertText(text, span)` 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。 + +`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件。占用者在卸载时释放展开状态,并拥有活动专属反馈。 + ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 07d754d873..9004f8fd62 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -368,6 +368,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'conversation.input.activity': { kind: 'single', scope: 'session' }, 'conversation.composer.dock': { kind: 'list', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index b4cd6b9bdf..4036736374 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -220,6 +220,15 @@ export interface SessionInputResolver { * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { + /** @returns a revision-guarded copy of the current editor selection. */ + captureInsertion(): TokenSpan + /** + * Insert asynchronous text without replacing subsequent edits or reference chips. + * @param text - plain text to insert in one undo step. + * @param span - captured selection and draft revision. + * @returns false when the draft changed or submission locked the editor. + */ + insertText(text: string, span: TokenSpan): boolean /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 023babf26e..5f57d69b5d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -197,6 +197,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.left': { kind: 'list'; scope: 'session' } /** Compact controls before the composer submit action. */ 'conversation.input.right': { kind: 'list'; scope: 'session' } + /** Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action. */ + 'conversation.input.activity': { kind: 'single'; scope: 'session'; owner: InputActivityOwnerProps } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } /** Optional draft-attachment rail and drop target. */ @@ -393,6 +395,12 @@ export interface InputControlOwnerProps { locked: boolean } +/** A toolbar activity hides ordinary accessory controls while expanded; its occupant must release expansion on unmount. */ +export interface InputActivityOwnerProps extends InputControlOwnerProps { + /** @param active - whether the occupant needs the toolbar width before the submit action. */ + onActiveChange: (active: boolean) => void +} + /** Full props of the resident composer bar. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> @@ -400,7 +408,7 @@ export type ComposerBarProps = | 'conversation.input.attachments' | 'conversation.input.overlay' | 'conversation.input.permission' | 'conversation.input.left' | 'conversation.input.plan' - | 'conversation.input.right' | 'conversation.input.model' + | 'conversation.input.right' | 'conversation.input.model' | 'conversation.input.activity' | 'conversation.composer.dock' > & InjectFace diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 2054624342..ca6047c4ec 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -210,6 +210,19 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an asynchronous text result as one independent undo operation. + * @param span - owner-validated insertion range. + * @param text - text sanitized with the same rules as paste. + * @returns whether the range mapped and the edit applied. + */ + insertAsyncText(span: DetectSpan, text: string): boolean { + let applied = false + const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') + this.applyEdit(() => { applied = $replaceDetectSpanWithText(span, clean) }, PASTE_TAG) + return applied + } + /** * Insert a reference chip with the existing trailing-space rule. * @param span - detect-coordinate range. diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..8931d1c4d0 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -117,6 +117,12 @@ export class SessionInputShell implements SessionInput { } /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { + captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), + insertText: (text, span) => { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false + if (span.draftRev !== this.rev) return false + return this.draftEditor.insertAsyncText(span, text) + }, setDraft: (text) => { this.setDraft(text) }, addAttachments: ids => this.addAttachments(ids), removeAttachment: (id) => { this.removeAttachment(id) }, diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6226a18ffa..d0b6b7224e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -407,3 +407,10 @@ font-size: 12px; cursor: pointer; } + +.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; } +.tools[hidden], .standardControls[hidden], .contextControl[hidden] { display: none; } +.trailingActive { flex: 1; margin-left: 0; } +.activity, .contextControl { display: flex; align-items: center; flex: none; } +.activity:empty, .contextControl:empty { display: none; } +.activityExpanded { flex: 1; min-width: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6a4ea12740..7d7e834e64 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -55,6 +55,8 @@ export const InputBar = memo(function InputBar({ const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') + const [activity, setActivity] = useState(false) + useEffect(() => { setActivity(false) }, [sessionId]) const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const subagent = useSession(s => s.subagent) ?? null @@ -406,7 +408,7 @@ export const InputBar = memo(function InputBar({ showPlaceholder={draft === '' && attachments.length === 0 && !claimActive} />
-
+
) diff --git a/packages/client/ui-conversation/tests/async-insertion.client.spec.ts b/packages/client/ui-conversation/tests/async-insertion.client.spec.ts new file mode 100644 index 0000000000..ef7cf1fcb9 --- /dev/null +++ b/packages/client/ui-conversation/tests/async-insertion.client.spec.ts @@ -0,0 +1,38 @@ +/** Late input insertions preserve references, subsequent edits, and undo ownership. */ +import { Context } from '@deepseek-ai/cordis' +import { UNDO_COMMAND } from 'lexical' +import { expect, it, vi } from 'vitest' +import { SessionInputShell } from '../src/client/input/facade.ts' + +function shell(): SessionInputShell { + return new SessionInputShell({ actx: new Context(), defaultSink: vi.fn(), commandAttachments: { + serialize: async () => [], release: () => {}, unsupportedNotice: () => '', + } }) +} + +it('inserts text beside an existing reference and undoes only that insertion', async () => { + const input = shell() + try { + input.setDraft('@ref') + input.insertReference({ source: 'file', ref: 'src/main.ts', label: 'main.ts', clipboardText: '@[main.ts](src/main.ts)' }, + { start: 0, end: 4, draftRev: input.state.getSnapshot().draftRev }) + const original = input.state.getSnapshot() + expect(input.actions.insertText(' check types', input.actions.captureInsertion())).toBe(true) + expect(input.state.getSnapshot().draft).toContain('check types') + expect(input.state.getSnapshot().occurrences).toEqual(original.occurrences) + input.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(input.state.getSnapshot().draft).toBe(original.draft) }) + } finally { input.dispose() } +}) + +it('refuses results captured before a user edit or after editor disposal', () => { + const input = shell() + input.setDraft('before') + const span = input.actions.captureInsertion() + input.setDraft('my newer text') + expect(input.actions.insertText('late voice', span)).toBe(false) + expect(input.state.getSnapshot().draft).toBe('my newer text') + const latest = input.actions.captureInsertion() + input.dispose() + expect(input.actions.insertText('late voice', latest)).toBe(false) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9e0366952c..bb79a736c7 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -19,6 +19,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -26,7 +27,7 @@ import type { SubmitOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts' import type { - ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads, + ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads, InputActivityOwnerProps, } from '../src/client/contract/slots.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' @@ -92,6 +93,8 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + activityEntry?: (owner: InputActivityOwnerProps) => React.ReactNode + contextPressure?: ContextPressureProjection footer?: React.ReactNode attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ @@ -162,6 +165,7 @@ function bench(over?: BenchOptions) { if (key === 'conversation.input.plan') return over?.planEntry ?? null if (key === 'conversation.input.permission') return over?.permissionEntry ?? null if (key === 'conversation.input.model') return over?.modelEntry ?? null + if (key === 'conversation.input.activity') return over?.activityEntry?.(owner as InputActivityOwnerProps) ?? null return null }) as never const props: InputBarProps = { @@ -184,7 +188,8 @@ function bench(over?: BenchOptions) { (selector ?? (v => v))(key === 'plan' ? over?.plan : key === 'goal' ? over?.goal - : key === 'imageLimits' ? over?.imageLimits : undefined)), + : key === 'imageLimits' ? over?.imageLimits + : key === 'contextPressure' ? over?.contextPressure : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -1610,7 +1615,7 @@ describe('command launcher chrome and control seats', () => { expect([...new Set(slotCalls.map(c => c.key))]).toEqual([ 'conversation.input.overlay', 'conversation.input.attachments', 'conversation.input.permission', 'conversation.input.plan', 'conversation.input.left', - 'conversation.input.right', 'conversation.input.model', + 'conversation.input.right', 'conversation.input.model', 'conversation.input.activity', 'conversation.composer.dock', ]) expect(view.queryByLabelText('Plan mode')).toBeNull() @@ -1673,3 +1678,40 @@ describe('command launcher chrome and control seats', () => { expect((live.view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(false) }) }) + +it('lets a toolbar activity replace accessories without replacing the draft editor or send action', () => { + const { view } = bench({ draft: 'keep this draft', modelEntry: , + activityEntry: owner => <> + + + , + }) + const editor = view.getByRole('textbox') + fireEvent.click(view.getByRole('button', { name: 'expand activity' })) + expect(view.queryByRole('button', { name: 'model choice' })).toBeNull() + expect(view.getByRole('textbox')).toBe(editor) + expect(editor.textContent).toBe('keep this draft') + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'close activity' })) + expect(view.getByRole('button', { name: 'model choice' })).toBeTruthy() +}) + +it('places context usage below the composer and hides it until the activity closes', () => { + const { view } = bench({ draft: 'draft', contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + activityEntry: owner => <> + + + , + }) + const meter = view.getByRole('button', { name: '上下文已用 25%' }) + const microphone = view.getByRole('button', { name: 'microphone' }) + expect(microphone.compareDocumentPosition(meter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(microphone) + expect(view.queryByRole('button', { name: '上下文已用 25%' })).toBeNull() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'close activity' })) + expect(view.getByRole('button', { name: '上下文已用 25%' })).toBe(meter) + fireEvent.click(meter) + expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index df24d45717..2489489230 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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: c04a614668d5afb2e09c1f923ac9be473a4aab5a -README.zh.md: f0b30c91cb87ce96349d92086673809139956e0a +README.md: 75d972a310f7f5fc505775bcc72d2f18675d0c89 +README.zh.md: 66c441950ba288f554e2e41750cfe4e0c0b0b359 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c04a614668..75d972a310 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -77,7 +77,9 @@ 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. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index f0b30c91cb..66c441950b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -77,7 +77,9 @@ 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 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 5ef0212a3c..dfa77f913c 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -37,7 +37,7 @@ import css from './PluginManagerPage.module.css' export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> - & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config'> + & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.activation'> & InjectFace /** The page's slot renderer, narrowed to the configuration slots. */ @@ -1072,6 +1072,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const ledger = props.useConfigLedger(snapshot => snapshot) // What is open; a package that leaves the list (uninstalled) drops back to the cards. const [view, setView] = useState({ kind: 'list' }) + const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) // A package an install just enabled: scroll it into view and mark it for a moment. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -1096,6 +1097,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined + const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name)) const setRowEnabled = (row: PackageRow, enabled: boolean): void => { /* v8 ignore next -- a row without a live entry has its switch disabled */ if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) @@ -1112,8 +1114,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { resolveText={resolveText} busy={state.busy.includes(pkg.name)} highlighted={state.highlight === pkg.name} - onOpen={() => { setView({ kind: 'package', name: pkg.name }) }} - onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name }) }} + onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }} /> ) // The Official group: the bundles the installation ships, then the plugins that registered their configuration. @@ -1242,6 +1244,12 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} + {showsCards && activated !== undefined && !state.install.open + ? renderSlot('plugins.bundle.activation', { + packageName: activated.name, + onDismiss: () => { setActivation(null) }, + onOpenDetails: () => { setActivation(null); setView({ kind: 'package', name: activated.name }) }, + }, { entryKey: activated.name }) : null} { setActivation(state.install.installed); props.enableInstalled() }} onApproveBuilds={props.approveBuildsAndRetry} onToggleRegistry={props.toggleRegistryOptions} onChooseRegistry={props.chooseRegistry} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index f3394b69cc..e0f9e1ea26 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -31,7 +31,7 @@ export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' -export type { PluginConfigViewProps } from './slot-contract.ts' +export type { PluginConfigViewProps, PluginActivationOwnerProps } from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -87,6 +87,7 @@ export function apply(ctx: ClientContext): void { inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), children: { 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, }, diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index c76633ff97..b5f004b0ac 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -8,12 +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', -]) - /** The registries with a name of their own, by host. */ const REGISTRY_COPY = new Map([ ['registry.npmmirror.com', 'registryNpmmirror'], @@ -105,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-'), } } diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 89a9b52115..4f291b865f 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -19,8 +19,19 @@ export interface PluginConfigViewProps { readonly view: 'summary' | 'page' } +/** One user-requested bundle activation and navigation to its configuration page. */ +export interface PluginActivationOwnerProps { + readonly packageName: string + /** Dismiss guidance for this activation. */ + readonly onDismiss: () => void + /** Dismiss guidance and open this bundle's detail page. */ + readonly onOpenDetails: () => void +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */ + 'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps } /** * One official plugin the Plugins page lists in its Official group after * the official bundles: `label` is the card's title and `order` its place. diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 9aaa8c1cee..89bf43e9b0 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -12,6 +12,7 @@ import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' +import type { PluginActivationOwnerProps } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -66,7 +67,7 @@ const READY: PluginManagerState = { } /** Configuration entries a test supplies: what each slot cell renders, by `:` and the view asked for. */ -type SlotBodies = Record ReactNode> +type SlotBodies = Record ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } @@ -120,10 +121,11 @@ function renderTab(state: Partial = {}, config: Partial { const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`] if (body === undefined) return null + if (name === 'plugins.bundle.activation') return body('activation', owner) if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { throw new Error('Plugin configuration fixture requires a summary or page view') } - return body(owner.view) + return body(owner.view, owner) }, } const { rerender } = render() @@ -256,6 +258,7 @@ describe('PluginManagerPage', () => { it.each([ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-auto-review', + '@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 = { @@ -272,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) @@ -1193,3 +1197,41 @@ describe('PluginManagerPage', () => { expect(actions.closeInstall).toHaveBeenCalledOnce() }) }) + +it('offers bundle-owned guidance only after explicit enablement and navigates to its detail page', () => { + const name = 'dsh-better-sidebar' + const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, { + [`plugins.bundle.activation:${name}`]: (_view, owner) => , + [`plugins.bundle.config:${name}`]: () =>
Bundle setup
, + }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg()] }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg({ enabled: false })] }) + fireEvent.click(screen.getByRole('switch')) + expect(actions.setEnabled).toHaveBeenCalledWith(name, true) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ packages: [pkg()], busy: [name] }) + expect(screen.queryByText('Go to setup')).toBeNull() + set({ busy: [] }) + fireEvent.click(screen.getByText('Go to setup')) + expect(screen.getByText('Bundle setup')).toBeTruthy() + expect(screen.queryByText('Go to setup')).toBeNull() +}) + +it('dismisses activation guidance until the user enables the bundle again', () => { + const name = 'dsh-better-sidebar' + const { set } = renderTab({ packages: [pkg({ enabled: false })] }, {}, { + [`plugins.bundle.activation:${name}`]: (_view, owner) => , + }) + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg()] }) + fireEvent.click(screen.getByText('Later')) + set({ packages: [pkg()] }) + expect(screen.queryByText('Later')).toBeNull() + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg({ enabled: false })] }) + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg()] }) + expect(screen.getByText('Later')).toBeTruthy() +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 5e3699e8e5..5f73b2ecc8 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: 2606bd0dd5895319c9887b15497b2ca6b7a045ce -README.zh.md: cdf816549ea6cf12382b819a493ded7f61ecddd9 +README.md: ed6f15f80031b2a9cc6403e95d645127d343c65f +README.zh.md: c4c04980f341677fbf8dd69d37b1a5968469f5fd diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 2606bd0dd5..ed6f15f800 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | -| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. | +| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `Modal` | Centered dialog over a page mask. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index cdf816549e..c4c04980f3 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,7 +44,7 @@ kind: "package-library" | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | -| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 | +| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `Modal` | 页面遮罩之上的居中对话框。 | diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index 186e54eb57..1a692c6892 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -93,3 +93,8 @@ stroke-dashoffset: -3; } } + +.step { display: inline-flex; flex: none; align-items: center; justify-content: center; box-sizing: border-box; border-radius: 50%; corner-shape: round; border: 1.5px solid var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-primary-foreground); } +.step[data-state='done'] { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); } +.step[data-state='error'] { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); } +.step[data-state='warning'] { border-color: var(--dsw-alias-state-warn-primary); } diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index b0d84b89ec..ede3c6b98c 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import css from './StateDot.module.css' +import { IconCheckOutlineRegular } from './icons/index.tsx' /** * State semantic: green done / amber user-attention / tertiary-grey loading / @@ -26,12 +27,14 @@ function syncSpinner(element: SVGSVGElement | null): void { * @param props.state - which of `done`, `warning`, `ongoing`, `error`, or `idle` to show. * @param props.size - outer diameter in px; defaults to 14 for ongoing and 10 for solid states. * @param props.className - extra class for layout placement. + * @param props.appearance - compact dot by default; step uses a filled check or hollow pending circle. * @returns the dot element (aria-hidden; pair with text for accessibility). */ -export function StateDot({ state, size, className }: { +export function StateDot({ state, size, className, appearance = 'dot' }: { state: StateDotState size?: number | undefined className?: string | undefined + appearance?: 'dot' | 'step' }) { const edge = size ?? (state === 'ongoing' ? 14 : 10) if (state === 'ongoing') { @@ -54,10 +57,10 @@ export function StateDot({ state, size, className }: { } return ( ) } diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 1a31577bb3..1c048370c1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1593,3 +1593,19 @@ export const IconSlidersTwoOutlineRegular = (props: IconProps) => ( export const IconSlidersTwoOutlineMedium = (props: IconProps) => ( ) + +const IconMicrophoneOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel microphone artwork. */ +export const IconMicrophoneOutlineRegular = (props: IconProps) => ( + +) + +/** Medium microphone artwork with a 1.3px stroke. */ +export const IconMicrophoneOutlineMedium = (props: IconProps) => ( + +) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index d3f950e63d..62f67def95 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,13 +17,14 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('product icon set', () => { - it('exports regular and medium variants for all 91 public glyphs', () => { - expect(iconNames.length).toBe(182) + it('exports regular and medium variants for all 92 public glyphs', () => { + expect(iconNames.length).toBe(184) expect(iconNames.some(name => /\d+$/.test(name))).toBe(false) const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length)) const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length)) expect(medium.sort()).toEqual(regular.sort()) expect(iconNames).toEqual(expect.arrayContaining([ + 'IconMicrophoneOutlineRegular', 'IconPlanOutlineRegular', 'IconCompactOutlineRegular', 'IconShieldOutlineRegular', 'IconDeliverDocRegular', 'IconWarningTriangleOutlineRegular', 'IconCompareSplitOutlineRegular', 'IconCloseCircleFillRegular', ])) diff --git a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx index d3e21fe772..5b6bad53f0 100644 --- a/packages/client/ui-primitives/tests/state-dot.client.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx @@ -57,6 +57,14 @@ describe('StateDot', () => { }) }) +it('renders completed steps with a check and pending steps without one', () => { + const { container, rerender } = render() + expect(container.querySelector('[data-state="done"] svg')).toBeTruthy() + rerender() + expect(container.querySelector('[data-state="idle"] svg')).toBeNull() + expect(container.firstElementChild?.className).toContain('step') +}) + describe('StateDot ongoing phase', () => { it('pins every loader animation to document time zero on mount', () => { const animations = [{ startTime: 42 }, { startTime: 7 }] diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index c314c6e669..3c2c0186b5 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -96,6 +96,8 @@ function props( useChat: unused, useTrajectory: unused, inputActions: { + captureInsertion: unused, + insertText: unused, setDraft: unused, addAttachments: unused, removeAttachment: unused, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index ea8641c0e2..4a6c214cec 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -214,6 +214,8 @@ function standaloneProps( draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], }) const inputActions: InputActions = { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => {}, addAttachments: () => false, removeAttachment: () => {}, @@ -339,6 +341,8 @@ function mount(fixture: Awaited>) { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], })) const inputActions: InputActions = { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: vi.fn(), addAttachments: vi.fn(() => false), removeAttachment: vi.fn(), diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4daccc5a42..7505ce2428 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -127,6 +127,8 @@ const kit: Omit = { useProjection: (() => undefined), useInput: selector => selector(inputState), inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => { throw new Error('unused') }, addAttachments: () => { throw new Error('unused') }, removeAttachment: () => { throw new Error('unused') }, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e119213f65..325a377d53 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -125,6 +125,8 @@ const kitBase: Omit = useProjection: (() => undefined), useInput: selector => selector(inputState), inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => { throw new Error('unused') }, addAttachments: () => { throw new Error('unused') }, removeAttachment: () => { throw new Error('unused') }, diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 5f7df5979e..0bbb40502b 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -326,6 +326,8 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi useTrajectory: selector => selector(panelTrajectory), useInput: () => { throw new Error('unused') }, inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => {}, addAttachments: () => false, removeAttachment: () => {}, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 73647081a7..3de64a30c6 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -445,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.composer.dock', @@ -698,6 +698,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-workspace/src/client/contract/slots.ts:99', }, + { + key: 'conversation.input.activity', + kind: 'single', + scope: 'session', + summary: 'Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action.', + doc: 'Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action.', + registerOptions: [], + ownerProps: [ + '/** A toolbar activity hides ordinary accessory controls while expanded; its occupant must release expansion on unmount. */\nexport interface InputActivityOwnerProps extends InputControlOwnerProps {\n /** @param active - whether the occupant needs the toolbar width before the submit action. */\n onActiveChange: (active: boolean) => void\n}', + ], + ownerPropsReferences: [ + 'InputControlOwnerProps', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', + }, { key: 'conversation.input.attachments', kind: 'single', @@ -737,7 +776,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', }, { key: 'conversation.input.dock', @@ -893,7 +932,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:215', }, { key: 'conversation.input.overlay', @@ -990,7 +1029,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:211', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', }, { key: 'conversation.input.plan', @@ -1029,7 +1068,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:209', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:211', }, { key: 'conversation.input.right', @@ -1636,6 +1675,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', }, + { + key: 'plugins.bundle.activation', + kind: 'keyed', + scope: 'root', + summary: 'Optional guidance after the user enables a bundle from the list, keyed by npm package name.', + doc: 'Optional guidance after the user enables a bundle from the list, keyed by npm package name.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/** One user-requested bundle activation and navigation to its configuration page. */\nexport interface PluginActivationOwnerProps {\n readonly packageName: string\n /** Dismiss guidance for this activation. */\n readonly onDismiss: () => void\n /** Dismiss guidance and open this bundle\'s detail page. */\n readonly onOpenDetails: () => void\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:34', + }, { key: 'plugins.bundle.config', kind: 'keyed', @@ -1670,7 +1745,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:39', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:50', }, { key: 'plugins.item', @@ -1723,7 +1798,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:33', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:44', }, { key: 'plugins.row.config', @@ -1759,7 +1834,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:47', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:58', }, { key: 'rightbar', diff --git a/tsconfig.host.json b/tsconfig.host.json index 01cca0cb14..b15992769f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,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", From 9df1b6033abb3425340ca12ab85106811e5e358e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 21 Sep 2026 11:35:35 +0800 Subject: [PATCH 2/2] fix(web): keep the empty composer dock collapsed --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/skeleton/InputBar.module.css | 6 +++--- .../client/ui-conversation/src/client/skeleton/InputBar.tsx | 2 +- .../client/ui-conversation/tests/input-bar.client.spec.tsx | 6 ++++-- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 84c42ef4a7..e655890c11 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: 5e42c1e11bd33f764ae85ef029a127f99541fc71 -README.zh.md: 3827193e703e19df142e3526cc2bbc8b8e5eb57a +README.md: b1bfea3d4ccef1076ed337a8ec229b64c9044ec0 +README.zh.md: d529286f2609b02aad1ed26f69aa54d9ef9bc710 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5e42c1e11b..b1bfea3d4c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -122,7 +122,7 @@ The selector must be a pure function of the owner currency. Its non-null return `InputActions.captureInsertion()` captures the draft selection and revision; `insertText(text, span)` inserts one undoable plain-text edit only while that revision is current and the editor permits editing. Asynchronous consumers retain rejected results for user action. -`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls. The occupant releases expansion on unmount and owns any activity-specific feedback. +`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3827193e70..d529286f26 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -122,7 +122,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match `InputActions.captureInsertion()` 捕获草稿选区与版本;`insertText(text, span)` 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。 -`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件。占用者在卸载时释放展开状态,并拥有活动专属反馈。 +`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index d0b6b7224e..fbd07f2810 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -409,8 +409,8 @@ } .standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; } -.tools[hidden], .standardControls[hidden], .contextControl[hidden] { display: none; } +.tools[hidden], .standardControls[hidden] { display: none; } .trailingActive { flex: 1; margin-left: 0; } -.activity, .contextControl { display: flex; align-items: center; flex: none; } -.activity:empty, .contextControl:empty { display: none; } +.activity { display: flex; align-items: center; flex: none; } +.activity:empty { display: none; } .activityExpanded { flex: 1; min-width: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7d7e834e64..5b045fb05c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -492,7 +492,7 @@ export const InputBar = memo(function InputBar({ {variant === 'composer' && input !== undefined && sessionId !== undefined ? renderSlot('conversation.composer.dock', {}) : null} - + {activity ? null : }
) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index bb79a736c7..90ea3c1cb1 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1706,12 +1706,14 @@ it('places context usage below the composer and hides it until the activity clos const meter = view.getByRole('button', { name: '上下文已用 25%' }) const microphone = view.getByRole('button', { name: 'microphone' }) expect(microphone.compareDocumentPosition(meter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(meter) + expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() fireEvent.click(microphone) + expect(view.queryByRole('dialog', { name: '上下文已用' })).toBeNull() expect(view.queryByRole('button', { name: '上下文已用 25%' })).toBeNull() expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() fireEvent.click(view.getByRole('button', { name: 'close activity' })) - expect(view.getByRole('button', { name: '上下文已用 25%' })).toBe(meter) - fireEvent.click(meter) + fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' })) expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() })