diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index bbb270a1f8..545cd9ba11 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser } from 'playwright' import { expect, it, onTestFinished, vi } from 'vitest' -import type { SpeechInput, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import type { SpeechInput, SpeechPreparationState, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import type {} from '@deepseek-ai/dsh-experimental-speech-to-text' import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, webSnapshotMode, watchConsole, @@ -19,7 +19,7 @@ const interruptedExpected = fileURLToPath(new URL('../../../snapshots/web/voice- const recordingExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/recording.expected.md', import.meta.url)) const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url)) -it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submits only the reviewed transcript through Session replay', async () => { +it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from standby and submits only the reviewed transcript through Session replay', async () => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-voice-browser-')) const resources: { scaffold?: WebScaffold; browser?: Browser } = {} onTestFinished(async () => { @@ -40,11 +40,14 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi expect(input.audio.byteLength).toBeGreaterThan(44) return { text: prompt.slice(prefix.length), audioSeconds: 1, inferenceSeconds: 0.1 } }) + let preparation: SpeechPreparationState = { phase: 'unprepared' } + const readinessListeners = new Set<() => void>() await scaffold.ctx.plugin({ inject: ['speechToText'], apply(ctx) { ctx.effect(() => ctx.speechToText.register({ info: { id: 'sensevoice-local' as SpeechProviderId, name: 'Recorded recognizer', location: 'host-local', languages: ['auto'] }, preparation: { - snapshot: () => ({ phase: 'standby' }), subscribe: () => () => {}, + snapshot: () => preparation, + subscribe: (listener) => { readinessListeners.add(listener); return () => { readinessListeners.delete(listener) } }, prepare: () => { throw new Error('Cached resources must allow recording without preparation') }, cancel: async () => {}, }, @@ -68,6 +71,37 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi await page.goto(scaffold.authenticatedUrl) await connectFreshWorkspace(page, scaffold.workspaceCwd) const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true }) + const setupMic = page.getByRole('button', { name: 'Open voice plugin settings', exact: true }) + await setupMic.click() + const setup = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true }) + await setup.waitFor() + await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/setup.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + await setup.getByRole('button', { name: 'Later', exact: true }).click() + expect(await setup.count()).toBe(0) + expect(recognize).not.toHaveBeenCalled() + preparation = { phase: 'failed', message: 'Model preparation failed' } + for (const listener of readinessListeners) listener() + await setupMic.click() + const unavailable = page.getByRole('dialog', { name: 'Speech recognition is not ready', exact: true }) + await unavailable.waitFor() + await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/unavailable.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + await page.setViewportSize({ width: 420, height: 900 }) + const dialogBox = await unavailable.boundingBox() + expect(dialogBox!.x).toBeGreaterThanOrEqual(0) + expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(420) + await page.keyboard.press('Escape') + await unavailable.waitFor({ state: 'hidden' }) + await setupMic.click() + await unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true }).click() + await page.setViewportSize({ width: 1280, height: 900 }) + await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-voice-input-bundle"]') + .getByRole('button', { name: 'Retry preparation', exact: true }).waitFor() + expect(await unavailable.count()).toBe(0) + preparation = { phase: 'standby' } + for (const listener of readinessListeners) listener() + await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click() await mic.waitFor() const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox() const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 568cf01093..fa8a85d9fc 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: d89e56a1115d077865c49150ecbf4991a524cfe9 -README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5 +README.md: 7334834eb83ef3d257a7d1239ce6cdb14e53b201 +README.zh.md: 96197d54ae5388ebb49531e54b090cb397b60695 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d89e56a111..7334834eb8 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -101,7 +101,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -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. +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. The registration-owned navigation store selects the cards, a bundle, an official plugin, or a bundle row; leaving the page resets it to the list. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. 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 50699e4749..96197d54ae 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -101,7 +101,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `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 标记。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择卡片、某个组合包、某个官方插件或组合包的某一行;离开页面时重置为列表。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`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 bcc9ff0d40..fb5e1cc063 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,7 +21,8 @@ import { StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { createNavigationStore } from './navigation-store.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import type { PluginManagerLocaleKey } from './locales.ts' import { @@ -43,18 +44,12 @@ export type PluginManagerPageProps = | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > & InjectFace + & PropsStore> /** The page's slot renderer, narrowed to the configuration slots. */ type RenderConfig = PluginManagerPageProps['renderSlot'] type ResolveText = PluginManagerFace['resolveText'] -/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */ -type View = - | { readonly kind: 'list' } - | { readonly kind: 'package'; readonly name: string } - | { readonly kind: 'item'; readonly id: string } - | { readonly kind: 'row'; readonly name: string; readonly rowId: string } - type RowPhase = NonNullable /** How long the list marks a package an install just enabled. */ @@ -1146,9 +1141,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const state = props.usePluginManager(snapshot => snapshot) const ledger = props.useConfigLedger(snapshot => snapshot) // What is open; a package that leaves the list (uninstalled) drops back to the cards. - const [view, setView] = useState({ kind: 'list' }) + const view = props.useStore(state => state.view), { setView } = props.actions const [activation, setActivation] = useState(null) useEffect(() => { ensure() }, [ensure]) + useEffect(() => () => { setView({ kind: 'list' }) }, [setView]) // 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 } useEffect(() => { diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 476e3dc400..5974500545 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -25,8 +25,23 @@ import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { PluginManagerController } from './manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from './locales.ts' +import { createNavigationStore } from './navigation-store.ts' import type {} from './slot-contract.ts' +declare module '@deepseek-ai/cordis' { + interface Context { + /** Cross-plugin navigation to the Plugins panel. */ + pluginNavigation: { + /** + * Open a bundle's details without changing the current Session. + * An absent bundle displays the plugin list after loading. + * @param packageName - npm package name of the bundle. + */ + openBundle(packageName: string): void + } + } +} + export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' @@ -49,7 +64,7 @@ export const NS = 'pluginManager' export const PANEL_ID = 'plugins' as MainPanelId /** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] /** * Contribute the Plugins entry to the sidebar with the management page it @@ -82,21 +97,33 @@ export function apply(ctx: ClientContext): void { // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) - ctx.slots.inject('main', () => ctx.slots.register({ - name: 'main', - key: PANEL_ID, - locale: NS, - 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' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage)) + ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + 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' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, diff --git a/packages/client/ui-plugin-manager/src/client/navigation-store.ts b/packages/client/ui-plugin-manager/src/client/navigation-store.ts new file mode 100644 index 0000000000..3b05463238 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/navigation-store.ts @@ -0,0 +1,25 @@ +/** Plugin page selection shared by the page and cross-plugin navigation. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' + +/** The plugin list or one bundle, official item, or bundle row. */ +type View = + | { readonly kind: 'list' } + | { readonly kind: 'package'; readonly name: string } + | { readonly kind: 'item'; readonly id: string } + | { readonly kind: 'row'; readonly name: string; readonly rowId: string } + +type NavigationState = { view: View } +type NavigationActions = { setView: (draft: NavigationState, view: View) => void } + +/** + * Create plugin page selection before the first page render. + * @returns the registration-owned navigation store handle. + */ +export function createNavigationStore(): EngineStoreHandle { + return defineStore({ + init: (): NavigationState => ({ view: { kind: 'list' } }), + actions: { + setView: (draft, view: View) => { draft.view = view }, + }, + }) +} diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index d58a3fb0bd..e94702eb6f 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import assert from 'node:assert/strict' import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' @@ -38,8 +39,11 @@ async function bench() { registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })), }, }) + const selectPanel = vi.fn() + ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal, + toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel } } function declare(slots: SlotRegistry): () => void { @@ -54,7 +58,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-plugin-manager browser plugin', () => { it('declares only the services the page and its Remote methods use', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { @@ -63,7 +67,11 @@ describe('ui-plugin-manager browser plugin', () => { const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() + b.ctx.pluginNavigation.openBundle('dsh-navigation-test') + expect(b.selectPanel).toHaveBeenCalledWith(PANEL_ID) const entry = b.slots.entries('main')[0]! + assert(entry.store && 'create' in entry.store) + expect(entry.store.create().getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) expect(entry.component).toBe(PluginManagerPage) expect(entry.options).toMatchObject({ key: PANEL_ID }) expect(entry.locale).toBe(NS) @@ -113,6 +121,7 @@ describe('ui-plugin-manager browser plugin', () => { expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([]) await fiber.dispose() + expect(b.ctx.get('pluginNavigation')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) 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 c3ecd56900..5bf28cdace 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -9,6 +9,7 @@ import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-te import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ReactNode } from 'react' +import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' @@ -126,7 +127,9 @@ function renderTab( useSessionRetainInfo: unusedStandardHook, useResource: unusedStandardHook, } + const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { + useStore: bindSnapshotSelector(navigation), actions: navigation.actions, ...standard, t, resolveText, @@ -157,6 +160,7 @@ function renderTab( } const { rerender } = render() return { + navigation, store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, @@ -168,6 +172,16 @@ function renderTab( } describe('PluginManagerPage', () => { + it('opens the requested bundle after its inventory arrives and falls back when it is absent', () => { + const b = renderTab({ status: 'loading' }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + b.set({ status: 'ready', packages: [pkg()] }) + expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'missing' }) }) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() + }) + it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { const { actions, set } = renderTab({ status: 'loading' }) expect(actions.ensure).toHaveBeenCalledTimes(1) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index 50b00dd654..18472c4d8c 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/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/experimental/client-ui-voice-input/README.md -README.md: 764962ff496c9fdd91073c2aa391ebceeec23608 -README.zh.md: 105cb641684eb2920c04294e74f1b5389f564c0c +README.md: debf20f7be0eedd5220387471a0be21b337f12ed +README.zh.md: 88c256d37d5abc6a5767c9785773308efd8eb4d0 diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 764962ff49..debf20f7be 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This optional browser plugin adds an outline microphone icon between the model selector and Send. Clicking it opens a recording toolbar with measured audio levels, Cancel and Stop. The waveform stays shorter than the recording buttons. Stopping transcribes into the draft. Recognition preferences and model preparation live in plugin settings; language choices come from the selected provider. +This optional browser plugin adds an outline microphone icon between the model selector and Send. When recognition is ready, clicking it opens a recording toolbar with measured audio levels, Cancel and Stop. The waveform stays shorter than the recording buttons. Stopping transcribes into the draft. Recognition preferences and model preparation live in plugin settings; language choices come from the selected provider. ## Table of Contents @@ -25,7 +25,7 @@ This optional browser plugin adds an outline microphone icon between the model s ## Use this package -Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. +Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before recognition is ready, guidance appears only on click, with no microphone tooltip. Clicking the microphone opens the same guidance dialog; its action opens the voice bundle details without recording or downloading. Preparation in progress, failures and disconnected services instead show a status-oriented prompt. Later or Escape closes the prompt without changing the draft. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission. Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready. diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index 105cb64168..88c256d37d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -此可选浏览器插件在模型选择器与发送按钮之间添加空心麦克风图标。点击后展开录音栏,显示实际音量以及取消、停止按钮。声波高度低于录音按钮高度。停止后转写到草稿。识别偏好和模型准备放在插件设置中;语言选项来自所选 Provider。 +此可选浏览器插件在模型选择器与发送按钮之间添加空心麦克风图标。识别就绪后,点击展开录音栏,显示实际音量以及取消、停止按钮。声波高度低于录音按钮高度。停止后转写到草稿。识别偏好和模型准备放在插件设置中;语言选项来自所选 Provider。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 +在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。正在准备、准备失败或服务断连时,弹窗改为引导查看服务状态。选择“稍后”或按 Escape 关闭提示,不改变草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index dfef2387ee..67f4e171d6 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -8,12 +8,15 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RecordingError, audioBase64, type Recording } from './audio.ts' import type { SpeechReadiness } from './readiness.ts' import { Waveform } from './Waveform.tsx' +import { VoiceSetupDialog } from './VoiceSetupPrompt.tsx' import { NS } from './locales.ts' import { Button, IconCloseOutlineRegular, IconStopFillRegular, IconMicrophoneOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import css from './VoiceInput.module.css' /** Host calls injected without exposing a Cordis Context to React. */ export interface VoiceInputActions { + /** Open the voice bundle details without starting preparation. */ + openSettings: () => void /** @returns one microphone operation owned by the plugin lifecycle. */ createRecording: () => Recording transcribe: (request: TranscriptionRequest, signal: AbortSignal) => Promise> @@ -51,12 +54,14 @@ async function disposeRecording(capture: Recording): Promise { /** Render a compact microphone or an expanded capture, transcription, or retry row. */ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, - createRecording, transcribe, useSpeechReadiness, t }: VoiceInputProps) { + createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId) const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby' || provider?.preparation.phase === 'waking') const [phase, setPhase] = useState('idle'), [message, setMessage] = useState(''), [pending, setPending] = useState('') + const [setupOpen, setSetupOpen] = useState(false) + useEffect(() => { if (usable) setSetupOpen(false) }, [usable]) const current = useRef(), generation = useRef(0) const expanded = phase !== 'idle' useLayoutEffect(() => { onActiveChange(expanded); return () => { onActiveChange(false) } }, [expanded, onActiveChange]) @@ -66,10 +71,10 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, const active = current.current current.current = undefined if (active) { clearTimeout(active.timer); active.abort.abort(); void disposeRecording(active.capture) } - setPending(''); setMessage(''); setPhase('idle') + setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false) } useEffect(() => { - setPending(''); setMessage(''); setPhase('idle') + setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false) const blur = (): void => { if (current.current?.phase === 'recording') cancel() } const visibility = (): void => { if (document.hidden && current.current && current.current.phase !== 'transcribing') cancel() @@ -137,11 +142,16 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, if (run === generation.current) { current.current = undefined; feedback(failureText(failure)) } } } - if (!expanded) return - - + if (!expanded) return <> + + + + { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + return
diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx index 2180284c2f..89d656df10 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceSetupPrompt.tsx @@ -10,6 +10,25 @@ import type { NS } from './locales.ts' export type VoiceSetupPromptProps = PropsRuntime<'plugins.bundle.activation'> & PropsLocale & Pick, 'useSpeechReadiness'> +type VoiceSetupDialogProps = Pick + & { open: boolean; needsInstallation: boolean } + +/** + * Guide activation or microphone clicks to the existing plugin details. + * @param props - visibility, installation need and navigation callbacks. + * @returns a dismissible prompt that never starts preparation or recording. + */ +export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDetails, t }: VoiceSetupDialogProps) { + return + + + }> +

{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}

+
+} + /** * Offer navigation to installation without starting a download. * @param props - activation navigation and the shared Host readiness observer. @@ -23,11 +42,5 @@ export function VoiceSetupPrompt({ useSpeechReadiness, onDismiss, onOpenDetails, useEffect(() => { if (phase !== undefined && phase !== 'checking' && !needsSetup) onDismiss() }, [phase, needsSetup, onDismiss]) - return - - - }> -

{t('setupPrompt.body')}

-
+ return } diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index 4d145cc35d..af57d1359b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -10,6 +10,9 @@ export const zh = { 'setupPrompt.body': '语音输入插件已开启。首次使用需要在本机下载并准备识别模型,请前往插件详情页查看空间、内存和时间说明,再开始安装。', 'setupPrompt.later': '稍后', 'setupPrompt.open': '前往安装', + 'setupPrompt.details': '前往语音插件设置', + 'setupPrompt.unavailableTitle': '语音识别尚未就绪', + 'setupPrompt.unavailableBody': '请前往语音插件详情页查看识别服务状态并完成准备', 'setup.local': '将在运行 DSH 的机器上下载本地模型,无需安装 Python 或编译工具。', 'setup.disk': '硬盘空间', 'setup.diskValue': '建议预留约 {gb} GB,包含模型、运行时和下载缓存', @@ -75,7 +78,6 @@ export const zh = { 'preparation.cancelling': '正在取消准备…', cloudReady: '云端语音识别已就绪。', - prepareRequired: '请先在语音插件设置中准备识别服务', discard: '丢弃识别文字', transcribingShort: '识别中…', wakingShort: '唤醒中…', provider: '识别服务', language: '识别语言', @@ -103,6 +105,9 @@ export const en: Record = { 'setupPrompt.body': 'Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup.', 'setupPrompt.later': 'Later', 'setupPrompt.open': 'Go to setup', + 'setupPrompt.details': 'Open voice plugin settings', + 'setupPrompt.unavailableTitle': 'Speech recognition is not ready', + 'setupPrompt.unavailableBody': 'Open the voice plugin details to check recognition status and finish preparation.', 'setup.local': 'Local models will be downloaded to the machine running DSH. No Python or compiler is required.', 'setup.disk': 'Disk space', 'setup.diskValue': 'Allow about {gb} GB for models, runtime, and download caches', @@ -168,7 +173,6 @@ export const en: Record = { 'preparation.cancelling': 'Cancelling preparation…', cloudReady: 'Cloud speech recognition is ready.', - prepareRequired: 'Prepare recognition in the voice plugin settings first', discard: 'Discard transcript', transcribingShort: 'Transcribing…', wakingShort: 'Waking…', provider: 'Recognition service', language: 'Recognition language', diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index a3f0f2c2ae..c7b4eaedbf 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -14,7 +14,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale'] +export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en })) @@ -23,6 +23,7 @@ function registerUi(ctx: Context): void { ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) const actions: VoiceInputInjected = { + openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, hooks: { speechReadiness: readiness.state }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }) @@ -58,7 +59,7 @@ function registerUi(ctx: Context): void { */ export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi) try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error } return async () => { await ui.dispose(); await disposeRemote() } } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 638b5d913b..b119659af1 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -24,6 +24,7 @@ vi.mock('../src/client/readiness.ts', () => ({ observeReadiness: () => ({ /** Narrow the erased registry payload before exercising its registered actions. */ function assertVoiceActions(value: Record): asserts value is Record & VoiceInputInjected { + assert(typeof value.openSettings === 'function') assert(typeof value.createRecording === 'function') assert(typeof value.configure === 'function') assert(typeof value.prepare === 'function') @@ -42,6 +43,8 @@ async function fixture(fail = false) { } } new Remote() + const openBundle = vi.fn() + ctx.provide('pluginNavigation', { openBundle }) const configure = vi.fn(async () => ({ ok: true, value: {} })) const prepare = vi.fn(async () => ({ ok: true, value: {} })) const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} })) @@ -56,7 +59,7 @@ async function fixture(fail = false) { } } as never, () => null) if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot failed') }) - return { ctx, unmount, configure, prepare, cancelPreparation, transcribe } + return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { @@ -69,6 +72,8 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a expect(entry).toMatchObject({ locale: 'voice-input' }) const actions = entry!.inject!() assertVoiceActions(actions) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') const finished = actions.createRecording() assert(finished instanceof Recording) await finished.dispose() diff --git a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx index d3b9a22ac9..655cb1dee4 100644 --- a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx @@ -116,7 +116,7 @@ it('persists settings and reports disconnection in the detail card', async () => { id: 'cloud' as SpeechProviderId, name: 'Cloud', location: 'cloud', languages: ['auto', 'en', 'zh', 'ja', 'fr'], preparation: { phase: 'ready' } }], } }) const configure = vi.fn(async () => {}) - const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, + const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t, openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), transcribe: vi.fn(), createRecording: vi.fn() } const view = render() fireEvent.change(screen.getByLabelText(zh.provider), { target: { value: 'cloud' } }) diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index 698c3d85ee..5ee8540198 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -6,7 +6,7 @@ import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/d import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SpeechProviderId, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import type { SpeechPreparationState, SpeechProviderId, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { VoiceInput, type VoiceInputProps } from '../src/client/VoiceInput.tsx' import { RecordingError, Recording } from '../src/client/audio.ts' @@ -30,7 +30,7 @@ function fixture(recording?: Recording) { const transcribe = vi.fn<(request: unknown, signal: AbortSignal) => Promise>>( async () => ({ ok: true, value: transcript })) const props: VoiceInputProps = { sessionId: 'one' as SessionId, inputActions, transcribe, locked: false, onActiveChange: vi.fn(), - prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), + openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, t: makeTranslate(zh, commonZh) } const view = render() @@ -172,15 +172,18 @@ it('bounds recordings, indicates idle wake-up, and cancels an in-flight transcri expect(b.inputActions.insertText).not.toHaveBeenCalled() }) -it('rejects excessive audio and disables unavailable or locked recognition', async () => { +it('rejects excessive audio and keeps locked recognition disabled', async () => { const b = fixture(); b.capture.stop.mockResolvedValueOnce(new Uint8Array(101)) await start(); stop(); await screen.findByText(zh.tooLarge) expect(b.transcribe).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: zh.cancel })) act(() => { b.readiness.set({ catalog: null, connected: false, error: null }) }) - expect(screen.getByRole('button', { name: zh.start }).disabled).toBe(true) + expect(screen.getByRole('button', { name: zh['setupPrompt.details'] }).disabled).toBe(false) b.view.rerender() - fireEvent.click(screen.getByRole('button', { name: zh.start })) + const locked = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + expect(locked.disabled).toBe(true) + fireEvent.click(locked) + expect(screen.queryByRole('dialog')).toBeNull() expect(b.capture.start).toHaveBeenCalledOnce() }) @@ -291,13 +294,63 @@ it.each(['escape', 'hidden', 'session'])('discards pending permission after %s', expect(b.transcribe).not.toHaveBeenCalled() }) -it('explains preparation on hover even when the microphone is disabled', () => { +it('offers installation on click without a preparation tooltip', () => { const b = fixture(), state = b.readiness.getSnapshot() act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, ] } }) }) - const mic = screen.getByRole('button', { name: zh.start }) - expect(mic.disabled).toBe(true) + const mic = screen.getByRole('button', { name: zh['setupPrompt.details'] }) + expect(mic.disabled).toBe(false) fireEvent.mouseEnter(mic.parentElement!) - expect(screen.getByRole('tooltip').textContent).toBe(zh.prepareRequired) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.click(mic) + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.body']) + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.props.prepare).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.open'] })) + expect(b.props.openSettings).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each([ + { phase: 'checking', step: 'check', startedAt: 0 }, + { phase: 'downloading', resource: 'model', completedBytes: 0 }, + { phase: 'failed', message: 'download failed' }, + { phase: 'cancelled' }, +])('guides $phase recognition to its status page without starting recording or preparation', (preparation) => { + const b = fixture(), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.details'] })) + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.unavailableBody']) + expect(screen.queryByText(zh['setupPrompt.body'])).toBeNull() + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.props.prepare).not.toHaveBeenCalled() + fireEvent.click(screen.getAllByRole('button', { name: zh['setupPrompt.details'] }).at(-1)!) + expect(b.props.openSettings).toHaveBeenCalledOnce() +}) + +it.each(['later', 'escape', 'ready', 'session'])('dismisses unavailable recognition guidance on %s without changing the draft', (reason) => { + const b = fixture(), ready = b.readiness.getSnapshot() + act(() => { b.readiness.set({ catalog: null, connected: false, error: 'offline' }) }) + const open = () => { fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.details'] })) } + open() + expect(screen.getByRole('dialog').textContent).toContain(zh['setupPrompt.unavailableTitle']) + if (reason === 'later') fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.later'] })) + if (reason === 'escape') fireEvent.keyDown(document, { key: 'Escape' }) + if (reason === 'ready') act(() => { b.readiness.set(ready) }) + if (reason === 'session') b.view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(b.props.openSettings).not.toHaveBeenCalled() + expect(b.capture.start).not.toHaveBeenCalled() + expect(b.inputActions.setDraft).not.toHaveBeenCalled() + expect(b.inputActions.insertText).not.toHaveBeenCalled() + if (reason === 'ready') { + act(() => { b.readiness.set({ ...ready, connected: false }) }) + expect(screen.queryByRole('dialog')).toBeNull() + } else { + open() + expect(screen.getByRole('dialog')).toBeTruthy() + } }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0dde565986..f594f72866 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -186,6 +186,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API', + pluginNavigation: 'client-side bundle navigation — packages/client/ui-plugin-manager/README.md owns the API', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API', modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API', diff --git a/snapshots/web/voice-input/setup.expected.md b/snapshots/web/voice-input/setup.expected.md new file mode 100644 index 0000000000..1cfcd160e0 --- /dev/null +++ b/snapshots/web/voice-input/setup.expected.md @@ -0,0 +1,6 @@ +- dialog "Set up voice input before recording": + - heading "Set up voice input before recording" [level=2] + - button "Cancel" + - paragraph: Voice input is enabled. First use requires downloading and preparing local recognition models. Open the plugin details to review disk space, memory and time estimates before starting setup. + - button "Later" + - button "Go to setup" diff --git a/snapshots/web/voice-input/unavailable.expected.md b/snapshots/web/voice-input/unavailable.expected.md new file mode 100644 index 0000000000..1f33e445ca --- /dev/null +++ b/snapshots/web/voice-input/unavailable.expected.md @@ -0,0 +1,6 @@ +- dialog "Speech recognition is not ready": + - heading "Speech recognition is not ready" [level=2] + - button "Cancel" + - paragraph: Open the voice plugin details to check recognition status and finish preparation. + - button "Later" + - button "Open voice plugin settings"