feat(web): expose input plugin extension hooks
This commit is contained in:
1 parent
37535cf44a
commit
0aa6f94749
58 files changed
+646
-56
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: 48ad6bbcf515aac5734da1fd406ce70f0f4d6505
|
||||
README.zh.md: 795e62a0e119e40211387a12f9e4fa56fdde3e79
|
||||
README.md: 0c600aee8a4a0141eb46e0850d0283a9997d7115
|
||||
README.zh.md: 495b5e10ddb5d6a2ec1174e0217f1b94ba00eaab
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
})
|
||||
|
||||
installDesktopDirectoryPicker(() => mainWindow)
|
||||
installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
|
||||
@@ -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<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>, 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) })
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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<Session['setPermissionCheckHandler']>()
|
||||
const setPermissionRequestHandler = vi.fn<Session['setPermissionRequestHandler']>()
|
||||
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<boolean>(), 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()
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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<We
|
||||
await assertReplaySession(
|
||||
[...observedSessions.values()],
|
||||
replayFixture,
|
||||
mode,
|
||||
compareReplaySession === 'read-only' ? 'replay' : mode,
|
||||
`http://${browserHost}:${port}`,
|
||||
harnessHome,
|
||||
)
|
||||
|
||||
@@ -51,6 +51,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 docs/subsystems/slots.md
|
||||
slots.md: 1e820fd880163d565a72733372fa4f2cc70a62b5
|
||||
slots.zh.md: c5bec8e752b9d9e1922f608449ca3249d0477062
|
||||
slots.md: 3a2eb7d0ff7297f6c80fa083134144cbb7a6864e
|
||||
slots.zh.md: 16a529aa46837af0aaa675271e765bd88f29e623
|
||||
@@ -6,6 +6,8 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots
|
||||
|
||||
This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md).
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount.
|
||||
|
||||
## Declaration and lifecycle
|
||||
|
||||
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
|
||||
|
||||
@@ -6,6 +6,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
|
||||
|
||||
本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。
|
||||
|
||||
## 声明与生命周期
|
||||
|
||||
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
|
||||
|
||||
@@ -327,6 +327,8 @@ describe('ImageGallery', () => {
|
||||
useTrajectory,
|
||||
useInput,
|
||||
inputActions: {
|
||||
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
|
||||
insertText: () => false,
|
||||
setDraft: vi.fn(),
|
||||
addAttachments: vi.fn(() => true),
|
||||
removeAttachment: vi.fn(),
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -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` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件。占用者在卸载时释放展开状态,并拥有活动专属反馈。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ComposerBarInjected>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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; }
|
||||
@@ -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}
|
||||
/>
|
||||
<div className={css.row}>
|
||||
<div className={css.tools}>
|
||||
<div className={css.tools} hidden={activity}>
|
||||
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -437,11 +439,16 @@ export const InputBar = memo(function InputBar({
|
||||
? null
|
||||
: renderSlot('conversation.input.left', {})}
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{input === undefined || sessionId === undefined
|
||||
? null
|
||||
: renderSlot('conversation.input.right', {})}
|
||||
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
|
||||
<div className={clsx(css.trailing, activity && css.trailingActive)}>
|
||||
<div className={css.standardControls} hidden={activity}>
|
||||
{input === undefined || sessionId === undefined
|
||||
? null
|
||||
: renderSlot('conversation.input.right', {})}
|
||||
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
|
||||
</div>
|
||||
{input === undefined || sessionId === undefined ? null : <div className={activity ? css.activityExpanded : css.activity}>
|
||||
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
|
||||
</div>}
|
||||
{interruptible && (
|
||||
<Tooltip label={t('input.stop')} side="top" delayMs={500} disabled={stop === undefined}>
|
||||
<button
|
||||
@@ -485,7 +492,7 @@ export const InputBar = memo(function InputBar({
|
||||
{variant === 'composer' && input !== undefined && sessionId !== undefined
|
||||
? renderSlot('conversation.composer.dock', {})
|
||||
: null}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
<div className={css.contextControl} hidden={activity}><ContextMeter useProjection={useProjection} t={t} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: <button>model choice</button>,
|
||||
activityEntry: owner => <>
|
||||
<button onClick={() => { owner.onActiveChange(true) }}>expand activity</button>
|
||||
<button onClick={() => { owner.onActiveChange(false) }}>close activity</button>
|
||||
</>,
|
||||
})
|
||||
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 => <>
|
||||
<button onClick={() => { owner.onActiveChange(true) }}>microphone</button>
|
||||
<button onClick={() => { owner.onActiveChange(false) }}>close activity</button>
|
||||
</>,
|
||||
})
|
||||
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()
|
||||
})
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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 不会触发引导。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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<PluginManagerFace>
|
||||
|
||||
/** 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<View>({ kind: 'list' })
|
||||
const [activation, setActivation] = useState<string | null>(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}
|
||||
<InstallDialog
|
||||
install={state.install}
|
||||
t={t}
|
||||
@@ -1251,7 +1259,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
onCancel={props.cancelInstall}
|
||||
onReconcile={props.reconcileInstall}
|
||||
onToggleDetails={props.toggleInstallDetails}
|
||||
onEnableNow={props.enableInstalled}
|
||||
onEnableNow={() => { setActivation(state.install.installed); props.enableInstalled() }}
|
||||
onApproveBuilds={props.approveBuildsAndRetry}
|
||||
onToggleRegistry={props.toggleRegistryOptions}
|
||||
onChooseRegistry={props.chooseRegistry}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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<string, PluginManagerLocaleKey>([
|
||||
['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-'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<slot>:<cell>` and the view asked for. */
|
||||
type SlotBodies = Record<string, (view: 'summary' | 'page') => ReactNode>
|
||||
type SlotBodies = Record<string, (view: 'summary' | 'page' | 'activation', owner: unknown) => ReactNode>
|
||||
|
||||
const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() }
|
||||
|
||||
@@ -120,10 +121,11 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
|
||||
renderSlot: (name, owner, opts) => {
|
||||
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(<PluginManagerPage {...props} />)
|
||||
@@ -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) => <button onClick={(owner as PluginActivationOwnerProps).onOpenDetails}>Go to setup</button>,
|
||||
[`plugins.bundle.config:${name}`]: () => <div>Bundle setup</div>,
|
||||
})
|
||||
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) => <button onClick={(owner as PluginActivationOwnerProps).onDismiss}>Later</button>,
|
||||
})
|
||||
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()
|
||||
})
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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` | 页面遮罩之上的居中对话框。 |
|
||||
|
||||
@@ -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); }
|
||||
@@ -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 (
|
||||
<span
|
||||
className={clsx(css.dot, className)}
|
||||
className={clsx(appearance === 'step' ? css.step : css.dot, className)}
|
||||
data-state={state}
|
||||
style={{ width: edge, height: edge }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
>{appearance === 'step' && state === 'done' && <IconCheckOutlineRegular size={edge - 2} />}</span>
|
||||
)
|
||||
}
|
||||
@@ -1593,3 +1593,19 @@ export const IconSlidersTwoOutlineRegular = (props: IconProps) => (
|
||||
export const IconSlidersTwoOutlineMedium = (props: IconProps) => (
|
||||
<IconSlidersTwoOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconMicrophoneOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth={strokeWidth} aria-hidden="true">
|
||||
<rect x="6" y="1" width="4" height="8" rx="2" /><path d="M3 7v1a5 5 0 0 0 10 0V7M8 13v2m-3 0h6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular one-pixel microphone artwork. */
|
||||
export const IconMicrophoneOutlineRegular = (props: IconProps) => (
|
||||
<IconMicrophoneOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
|
||||
)
|
||||
|
||||
/** Medium microphone artwork with a 1.3px stroke. */
|
||||
export const IconMicrophoneOutlineMedium = (props: IconProps) => (
|
||||
<IconMicrophoneOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
@@ -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',
|
||||
]))
|
||||
|
||||
@@ -57,6 +57,14 @@ describe('StateDot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders completed steps with a check and pending steps without one', () => {
|
||||
const { container, rerender } = render(<StateDot state="done" appearance="step" size={16} />)
|
||||
expect(container.querySelector('[data-state="done"] svg')).toBeTruthy()
|
||||
rerender(<StateDot state="idle" appearance="step" size={16} />)
|
||||
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 }]
|
||||
|
||||
@@ -96,6 +96,8 @@ function props(
|
||||
useChat: unused,
|
||||
useTrajectory: unused,
|
||||
inputActions: {
|
||||
captureInsertion: unused,
|
||||
insertText: unused,
|
||||
setDraft: unused,
|
||||
addAttachments: unused,
|
||||
removeAttachment: unused,
|
||||
|
||||
@@ -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<ReturnType<typeof bench>>) {
|
||||
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(),
|
||||
|
||||
@@ -127,6 +127,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
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') },
|
||||
|
||||
@@ -125,6 +125,8 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> =
|
||||
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') },
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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<WorkspaceSnapshot>',
|
||||
'usePanelInfo: UsePanelInfo',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionStatus: UseSessionStatus',
|
||||
'useSessionRetainInfo: UseSessionRetainInfo',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useChat: UseChat',
|
||||
'useConversation: UseConversation',
|
||||
'useInput: SnapshotSelectorHook<InputState>',
|
||||
'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<WorkspaceSnapshot>',
|
||||
'usePanelInfo: UsePanelInfo',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionStatus: UseSessionStatus',
|
||||
'useSessionRetainInfo: UseSessionRetainInfo',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user