From 80deb09aa93ccb65622b83e3531ddd94eb873175 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:04:54 +0000 Subject: [PATCH] feat(dictation): fixed-size dictation pill with cancel, timer and finish (#40155) * feat(dictation): fixed-size dictation pill with cancel, timer and finish * fix(dictation): abort a cloud upload cancelled while finishing; time recorded audio * refactor(dictation): reuse Esc/IME helpers, keep the pane on the target, isolate meter renders * fix(dictation): pause the pill timer while hidden; compile under React Compiler --- src/main/ipc/speech.ts | 26 +- src/main/speech/stt-session-stop.ts | 4 + src/preload/api/speech-api.ts | 2 + src/preload/api/speech-bridge.ts | 5 + .../dictation/DictationController.tsx | 46 +++- .../components/dictation/DictationGrapes.tsx | 8 +- .../dictation/DictationIndicator.fixture.tsx | 56 ++++ .../dictation/DictationIndicator.tsx | 253 ++++++++++++------ .../dictation/dictation-control-events.ts | 2 +- .../dictation/dictation-insertion-target.ts | 12 +- .../dictation/dictation-pill-anchor.ts | 63 +++++ .../use-floating-terminal-focus-lifecycle.ts | 8 +- .../regular-terminal-focus-ownership.test.ts | 25 ++ .../focus/regular-terminal-focus-ownership.ts | 6 +- src/renderer/src/components/ui/button.tsx | 2 + src/renderer/src/i18n/locales/de.json | 8 +- src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/pt.json | 8 +- src/renderer/src/i18n/locales/ru.json | 8 +- src/renderer/src/i18n/locales/tr.json | 8 +- src/renderer/src/i18n/locales/zh-Hant.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../src/lib/app-keyboard-router.test.ts | 16 ++ src/renderer/src/lib/app-keyboard-router.ts | 13 +- src/renderer/src/lib/focus-keeping-press.ts | 6 + .../src/web/preload-api/web-speech-api.ts | 16 ++ src/shared/dictation-session-machine.test.ts | 6 + src/shared/dictation-session-machine.ts | 9 +- 32 files changed, 487 insertions(+), 187 deletions(-) create mode 100644 src/renderer/src/components/dictation/DictationIndicator.fixture.tsx create mode 100644 src/renderer/src/components/dictation/dictation-pill-anchor.ts create mode 100644 src/renderer/src/lib/focus-keeping-press.ts diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index feded50d21b..42f92f7445e 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -1,4 +1,10 @@ -import { ipcMain, BrowserWindow, systemPreferences, type MessagePortMain } from 'electron' +import { + ipcMain, + BrowserWindow, + systemPreferences, + type IpcMainInvokeEvent, + type MessagePortMain +} from 'electron' import { abortWhenRendererGone } from './renderer-lifetime-abort' import { join } from 'node:path' import { writeFile, unlink } from 'node:fs/promises' @@ -260,12 +266,16 @@ export function registerSpeechHandlers(store: Store): void { } ) - ipcMain.handle('speech:stopDictation', async (_event, sessionId = 'desktop') => { - const owner = getDesktopOwner(_event.sender.id, sessionId) - const listener = dictationListeners.get(_event.sender.id) - await getSpeechSttService(store).stopDictation(owner) - if (listener?.owner === owner) { - listener.cleanup() + const handleStop = + (discard: boolean) => + async (event: IpcMainInvokeEvent, sessionId = 'desktop'): Promise => { + const owner = getDesktopOwner(event.sender.id, sessionId) + const listener = dictationListeners.get(event.sender.id) + await getSpeechSttService(store).stopDictation(owner, { discard }) + if (listener?.owner === owner) { + listener.cleanup() + } } - }) + ipcMain.handle('speech:stopDictation', handleStop(false)) + ipcMain.handle('speech:cancelDictation', handleStop(true)) } diff --git a/src/main/speech/stt-session-stop.ts b/src/main/speech/stt-session-stop.ts index a1154526e37..1480be40b54 100644 --- a/src/main/speech/stt-session-stop.ts +++ b/src/main/speech/stt-session-stop.ts @@ -27,6 +27,10 @@ export function stopSttDictation( return Promise.resolve() } if (session.phase === 'stopping') { + // A cancel after Finish still aborts the cloud upload whose text it would throw away. + if (options.discard) { + session.cloud?.cancel() + } return session.stopPromise ?? Promise.resolve() } session.phase = 'stopping' diff --git a/src/preload/api/speech-api.ts b/src/preload/api/speech-api.ts index 2acb2d35da1..8acba71dcc2 100644 --- a/src/preload/api/speech-api.ts +++ b/src/preload/api/speech-api.ts @@ -27,6 +27,8 @@ export type SpeechApi = { /** Resolves true when this chunk filled a cloud session's upload cap. */ feedAudio: (samples: Float32Array, sampleRate: number, sessionId?: string) => Promise stopDictation: (sessionId?: string) => Promise + /** Drops the session's audio and any text it hasn't returned yet, without transcribing the rest. */ + cancelDictation: (sessionId: string) => Promise onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void onDownloadProgress: ( diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index 8222c564422..a0b38154039 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -84,6 +84,11 @@ export const speechApi = { } return ipcRenderer.invoke('speech:stopDictation', sessionId) }, + cancelDictation: (sessionId: string): Promise => { + audioPorts.get(sessionId)?.close() + audioPorts.delete(sessionId) + return ipcRenderer.invoke('speech:cancelDictation', sessionId) + }, onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index d509b0ef504..be1f066e7da 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -4,6 +4,10 @@ import { useAudioCapture } from '@/hooks/use-audio-capture' import { toast } from 'sonner' import { DictationIndicator } from './DictationIndicator' import { captureInsertionTarget } from './dictation-insertion-target' +import { dictationPillAnchorFor, type DictationPillAnchor } from './dictation-pill-anchor' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' +import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event' +import { isPlainEscapeKeyEvent } from '../terminal-pane/input/keyboard/agent-interrupt-key-event' import { createDictationTranscriptDelivery } from './dictation-transcript-delivery' import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped-sessions' import { translate } from '@/i18n/i18n' @@ -24,6 +28,7 @@ import { useDictationSpeechEvents } from './use-dictation-speech-events' import { canStartDictation, initialDictationSession, + isDictationPillShown, isDictationUnderway, transitionDictationSession, type DictationSession, @@ -56,6 +61,8 @@ export function DictationController() { // Push-to-talk restarts per utterance; report a missing preferred microphone once per preference. const micFallbackNotifiedForRef = useRef(null) const [captureLive, setCaptureLive] = useState(false) + // Holds the target pane only while the pill shows, so a closed pane isn't kept alive. + const [pillAnchor, setPillAnchor] = useState(null) const dispatch = useCallback( (event: DictationSessionEvent): DictationSession => { @@ -69,6 +76,7 @@ export function DictationController() { setDictationState(next.phase) if (next.phase === 'idle') { setPartialTranscript('') + setPillAnchor(null) } } return next @@ -113,6 +121,17 @@ export function DictationController() { [dispatch, finishCapture, getCapturedChunkCount] ) + // Words that already landed stay; the engine drops the rest instead of transcribing it. + const cancelDictation = useCallback(() => { + const previous = sessionRef.current + if (dispatch({ type: 'cancel' }) === previous || previous.sessionId === null) { + return + } + stopCapture() + void window.api.speech.cancelDictation(previous.sessionId).catch(() => undefined) + drainStoppedSession(previous.sessionId) + }, [dispatch, stopCapture, drainStoppedSession]) + const stopDictation = useCallback(async () => { const previous = sessionRef.current const next = dispatch({ type: 'stop' }) @@ -152,6 +171,7 @@ export function DictationController() { const deliver = createDictationTranscriptDelivery() deliverRef.current = (text) => deliver(text, insertionTarget) setCaptureLive(false) + setPillAnchor(dictationPillAnchorFor(insertionTarget)) // A stop that arrived while starting is still this run's stop. const stopQueued = (): boolean => isCurrent() && sessionRef.current.phase === 'stopping' @@ -161,7 +181,7 @@ export function DictationController() { stopCapture() } if (engineStarted) { - await window.api.speech.stopDictation(sessionId).catch(() => undefined) + await window.api.speech.cancelDictation(sessionId).catch(() => undefined) drainStoppedSession(sessionId) } } @@ -281,13 +301,33 @@ export function DictationController() { void startDictation() } else if (action === 'stop') { void stopDictation() + } else if (action === 'cancel') { + cancelDictation() } else { toggleDictation() } } document.addEventListener(DICTATION_CONTROL_EVENT, handleControl) return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) - }, [settings?.voice?.enabled, startDictation, stopDictation, toggleDictation]) + }, [settings?.voice?.enabled, startDictation, stopDictation, cancelDictation, toggleDictation]) + + // Esc cancels while the pill shows and goes no further, so it can't also interrupt the agent. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + !isPlainEscapeKeyEvent(event) || + isImeOwnedKeyboardEvent(event) || + !isDictationPillShown(sessionRef.current.phase) + ) { + return + } + event.preventDefault() + event.stopImmediatePropagation() + cancelDictation() + } + addAppKeyListener('keydown', handleKeyDown, { first: true }) + return () => removeAppKeyListener('keydown', handleKeyDown) + }, [cancelDictation]) useDictationSilenceStop( dictationState === 'listening', @@ -334,5 +374,5 @@ export function DictationController() { } }) - return + return } diff --git a/src/renderer/src/components/dictation/DictationGrapes.tsx b/src/renderer/src/components/dictation/DictationGrapes.tsx index a2629bf6392..8b80331c9b0 100644 --- a/src/renderer/src/components/dictation/DictationGrapes.tsx +++ b/src/renderer/src/components/dictation/DictationGrapes.tsx @@ -3,11 +3,12 @@ import { DICTATION_GRAPES } from '../../../../shared/dictation-audio-meter' type DictationGrapesProps = { level: number + /** Moves with the voice; otherwise the grapes rest and pulse. */ active: boolean - transitioning: boolean + className?: string } -export function DictationGrapes({ level, active, transitioning }: DictationGrapesProps) { +export function DictationGrapes({ level, active, className }: DictationGrapesProps) { const normalizedLevel = Math.min(1, Math.max(0, level)) return ( @@ -16,7 +17,8 @@ export function DictationGrapes({ level, active, transitioning }: DictationGrape aria-hidden="true" className={cn( 'flex h-6 w-11 shrink-0 items-center justify-center gap-px overflow-hidden', - transitioning && 'animate-pulse motion-reduce:animate-none' + !active && 'animate-pulse motion-reduce:animate-none', + className )} > {DICTATION_GRAPES.map((grape, index) => { diff --git a/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx b/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx new file mode 100644 index 00000000000..bfffcc98cd5 --- /dev/null +++ b/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx @@ -0,0 +1,56 @@ +import { useMemo, useState } from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { DictationIndicator } from './DictationIndicator' +import { publishDictationMeter, resetDictationMeter } from './dictation-meter-store' + +function PaneWithPill({ captureLive = true }: { captureLive?: boolean }) { + const [pane, setPane] = useState(null) + const anchor = useMemo( + () => (pane ? { element: pane, placement: 'over' as const } : null), + [pane] + ) + return ( +
+ {anchor ? : null} +
+ ) +} + +export const listening = defineFixture({ + width: 520, + store: { dictationState: 'listening' }, + setup: () => publishDictationMeter({ level: 0.5, isSpeaking: true, isClipping: false }), + render: () => +}) + +export const startingBeforeMic = defineFixture({ + width: 520, + store: { dictationState: 'starting' }, + setup: resetDictationMeter, + render: () => +}) + +export const liveText = defineFixture({ + width: 520, + store: { + dictationState: 'listening', + partialTranscript: + 'It should go back to the page you came from. Check where returnTo gets dropped' + }, + setup: () => publishDictationMeter({ level: 0.6, isSpeaking: true, isClipping: false }), + render: () => +}) + +export const tooLoud = defineFixture({ + width: 520, + store: { dictationState: 'listening' }, + setup: () => publishDictationMeter({ level: 1, isSpeaking: true, isClipping: true }), + render: () => +}) + +export const finishing = defineFixture({ + width: 520, + store: { dictationState: 'stopping' }, + setup: resetDictationMeter, + render: () => +}) diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index a231abedecf..3e396e959e1 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -1,114 +1,197 @@ +import { useEffect, useRef, useState } from 'react' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { useTranslate } from '@/i18n/use-translate' +import { KEEPS_FOCUS_ATTRIBUTE } from '@/lib/focus-keeping-press' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { Square } from 'lucide-react' +import { Check, Loader2, X } from 'lucide-react' +import { isDictationPillShown } from '../../../../shared/dictation-session-machine' import { dispatchDictationControl } from './dictation-control-events' import { DictationGrapes } from './DictationGrapes' import { useDictationMeter } from './dictation-meter-store' +import { useDictationPillBox, type DictationPillAnchor } from './dictation-pill-anchor' -// captureLive: the mic is recording (and buffering) while the speech engine is still starting. -export function DictationIndicator({ captureLive }: { captureLive: boolean }) { +// The finish button turns into a spinner only when finishing is slow enough to notice. +const FINISH_SPINNER_DELAY_MS = 200 + +type DictationIndicatorProps = { + /** The mic is recording (and buffering) while the speech engine is still starting. */ + captureLive: boolean + anchor: DictationPillAnchor | null +} + +export function DictationIndicator(props: DictationIndicatorProps) { + const dictationState = useAppStore((state) => state.dictationState) + return isDictationPillShown(dictationState) ? : null +} + +function formatElapsed(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` +} + +/** Seconds since `running` first turned on; holds still once it turns off. */ +function useElapsedSeconds(running: boolean): number { + const startedAtRef = useRef(null) + const [seconds, setSeconds] = useState(0) + useEffect(() => { + if (!running) { + return + } + if (startedAtRef.current === null) { + startedAtRef.current = performance.now() + } + const startedAt = startedAtRef.current + // Measured from the start, so ticks skipped while the window is hidden don't drift it. + return installWindowVisibilityInterval({ + run: () => setSeconds(Math.floor((performance.now() - startedAt) / 1000)), + intervalMs: 250 + }) + }, [running]) + return seconds +} + +function useDelayedFlag(on: boolean, delayMs: number): boolean { + const [delayed, setDelayed] = useState(false) + useEffect(() => { + if (!on) { + setDelayed(false) + return + } + const id = window.setTimeout(() => setDelayed(true), delayMs) + return () => window.clearTimeout(id) + }, [on, delayMs]) + return delayed +} + +// Only this re-renders per meter tick, not the whole pill. +function DictationPillMeter({ recording, status }: { recording: boolean; status: string }) { + const t = useTranslate() + const meter = useDictationMeter() + const clipping = recording && meter.isClipping + return ( + <> + + {clipping + ? t('auto.components.dictation.DictationIndicator.4977162383', 'Too loud') + : status} + + + + ) +} + +// Presses on the pill keep focus where the words are going. +const keepFocus = (event: React.MouseEvent): void => event.preventDefault() +const keepFocusProps = { [KEEPS_FOCUS_ATTRIBUTE]: '', onMouseDown: keepFocus } + +function DictationPill({ captureLive, anchor }: DictationIndicatorProps) { const t = useTranslate() const dictationState = useAppStore((state) => state.dictationState) const partialTranscript = useAppStore((state) => state.partialTranscript) - const dictationMeter = useDictationMeter() const isHoldMode = useAppStore((state) => state.settings?.voice?.dictationMode === 'hold') const shortcut = useShortcutKeyDetails('voice.dictation') + const box = useDictationPillBox(anchor) - const isVisible = ['listening', 'starting', 'stopping'].includes(dictationState) - if (!isVisible) { - return null - } - - const isLoadingModel = dictationState === 'starting' && captureLive - const isListening = dictationState === 'listening' || isLoadingModel - const isClipping = isListening && dictationMeter.isClipping - const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping - const lifecycleLabel = isListening - ? t('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening') - : dictationState === 'starting' - ? t('auto.components.dictation.DictationIndicator.7f3660a7ba', 'Starting mic…') - : t('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…') - const label = isClipping - ? t('auto.components.dictation.DictationIndicator.4977162383', 'Too loud') - : isSpeaking - ? t('auto.components.dictation.DictationIndicator.25f2b7a6a5', 'Speaking') - : lifecycleLabel - const announcedLabel = isClipping ? label : lifecycleLabel - const canStop = dictationState !== 'stopping' - const showShortcut = !isHoldMode && shortcut.keys.length > 0 + const finishing = dictationState === 'stopping' + const recording = dictationState === 'listening' || (dictationState === 'starting' && captureLive) + // Counts recorded time, so it waits for the mic to open. + const seconds = useElapsedSeconds(recording) + const showSpinner = useDelayedFlag(finishing, FINISH_SPINNER_DELAY_MS) const transcript = partialTranscript.trim() - const stopLabel = t('auto.components.dictation.DictationIndicator.335e1bc6cb', 'Stop dictation') + const status = finishing + ? t('auto.components.dictation.DictationIndicator.finishing', 'Finishing…') + : t('auto.components.dictation.DictationIndicator.listening', 'Listening…') + const cancelLabel = t('auto.components.dictation.DictationIndicator.cancel', 'Cancel') + const finishLabel = t('auto.components.dictation.DictationIndicator.finish', 'Finish') return (
-
- - - {label} - - {isLoadingModel ? ( - - {t( - 'auto.components.dictation.DictationIndicator.loadingModel', - 'Loading speech model…' - )} - - ) : null} - - {announcedLabel} - - {canStop ? ( - <> - - - - - - - {stopLabel} - {showShortcut ? ( - - ) : null} - - - - ) : null} -
+ + + + + + {cancelLabel} + + + {transcript ? ( -

- {transcript} -

- ) : null} + + {transcript} + + ) : ( + + {status} + + )} + + {formatElapsed(seconds)} + + {finishing ? ( + + {showSpinner ? ( + + ) : ( + + )} + + ) : ( + + + + + + + {finishLabel} + {!isHoldMode && shortcut.keys.length > 0 ? ( + + ) : null} + + + + )}
) } diff --git a/src/renderer/src/components/dictation/dictation-control-events.ts b/src/renderer/src/components/dictation/dictation-control-events.ts index df9ed811340..9dea9e131b3 100644 --- a/src/renderer/src/components/dictation/dictation-control-events.ts +++ b/src/renderer/src/components/dictation/dictation-control-events.ts @@ -3,7 +3,7 @@ export const DICTATION_CONTROL_EVENT = 'dictation:control' /** Dictation started with no text field focused; the pane that owns typing focuses its input. */ export const DICTATION_FOCUS_INPUT_EVENT = 'dictation:focusInput' -export type DictationControlAction = 'toggle' | 'start' | 'stop' +export type DictationControlAction = 'toggle' | 'start' | 'stop' | 'cancel' export function dispatchDictationControl(action: DictationControlAction): void { document.dispatchEvent( diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index ff4ff69141c..ace14f22e96 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -16,8 +16,8 @@ type ComposerInsert = (text: string) => boolean type TerminalInsert = (text: string, paneId: number) => Promise export type DictationInsertionTarget = - | { kind: 'composer'; insert: ComposerInsert } - | { kind: 'terminal'; tabId: string; paneId: number } + | { kind: 'composer'; insert: ComposerInsert; root: HTMLElement } + | { kind: 'terminal'; tabId: string; paneId: number; pane: HTMLElement } | { kind: 'text'; element: HTMLInputElement | HTMLTextAreaElement } | { kind: 'contentEditable'; element: HTMLElement } @@ -65,8 +65,8 @@ function readFocusedInsertionTarget(): DictationInsertionTarget | null { for (let root: Element | null = activeElement; root; root = root.parentElement) { const insert = root instanceof HTMLElement ? composerTargets.get(root) : undefined - if (insert) { - return { kind: 'composer', insert } + if (insert && root instanceof HTMLElement) { + return { kind: 'composer', insert, root } } } @@ -75,8 +75,8 @@ function readFocusedInsertionTarget(): DictationInsertionTarget | null { const tabElement = activeElement.closest('[data-terminal-tab-id]') as HTMLElement | null const paneId = Number(paneElement?.dataset.paneId) const tabId = tabElement?.dataset.terminalTabId - if (tabId && Number.isFinite(paneId)) { - return { kind: 'terminal', tabId, paneId } + if (paneElement && tabId && Number.isFinite(paneId)) { + return { kind: 'terminal', tabId, paneId, pane: paneElement } } return null } diff --git a/src/renderer/src/components/dictation/dictation-pill-anchor.ts b/src/renderer/src/components/dictation/dictation-pill-anchor.ts new file mode 100644 index 00000000000..5fff470ecb1 --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-pill-anchor.ts @@ -0,0 +1,63 @@ +import { useLayoutEffect, useState } from 'react' +import type { DictationInsertionTarget } from './dictation-insertion-target' + +/** The pane the words go to; the pill floats over its bottom edge, or just above a chat composer. */ +export type DictationPillAnchor = { element: HTMLElement; placement: 'over' | 'above' } + +/** Fixed-position box: `left` is the pill's centre, `bottom` its distance from the window's bottom edge. */ +type DictationPillBox = { left: number; bottom: number; width: number } + +const PILL_MAX_WIDTH = 440 +const PANE_INSET = 16 +const OVER_GAP = 12 +const ABOVE_GAP = 8 + +export function dictationPillAnchorFor( + target: DictationInsertionTarget | null +): DictationPillAnchor | null { + if (target?.kind === 'composer') { + return { element: target.root, placement: 'above' } + } + if (target?.kind === 'terminal') { + return { element: target.pane, placement: 'over' } + } + return null +} + +function readPillBox(anchor: DictationPillAnchor): DictationPillBox | null { + const { element, placement } = anchor + if (!element.isConnected || !element.checkVisibility({ visibilityProperty: true })) { + return null + } + const rect = element.getBoundingClientRect() + if (rect.width <= PANE_INSET * 2 || rect.height === 0) { + return null + } + const edge = placement === 'above' ? rect.top - ABOVE_GAP : rect.bottom - OVER_GAP + return { + left: rect.left + rect.width / 2, + bottom: window.innerHeight - edge, + width: Math.min(PILL_MAX_WIDTH, rect.width - PANE_INSET * 2) + } +} + +/** Follows the anchor pane's size; once it's hidden or gone, null puts the pill at the window's bottom centre. */ +export function useDictationPillBox(anchor: DictationPillAnchor | null): DictationPillBox | null { + const [box, setBox] = useState(null) + useLayoutEffect(() => { + if (!anchor) { + setBox(null) + return + } + const update = (): void => setBox(readPillBox(anchor)) + update() + const observer = new ResizeObserver(update) + observer.observe(anchor.element) + window.addEventListener('resize', update) + return () => { + observer.disconnect() + window.removeEventListener('resize', update) + } + }, [anchor]) + return box +} diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts index b3091e66d6c..eee2d5b02b4 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { isFocusKeepingPress } from '@/lib/focus-keeping-press' import { clearFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { reportFloatingFocus } from './floating-terminal-focus-reporting' @@ -34,7 +35,12 @@ export function useFloatingTerminalFocusLifecycle({ } const handleOutsidePointerDown = (event: PointerEvent): void => { const panel = panelRef.current - if (!panel || !(event.target instanceof Node) || panel.contains(event.target)) { + if ( + !panel || + !(event.target instanceof Node) || + panel.contains(event.target) || + isFocusKeepingPress(event.target) + ) { return } reportFloatingFocus(null, true) diff --git a/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.test.ts b/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.test.ts index 99636a94030..5ca21a867ad 100644 --- a/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.test.ts @@ -1,7 +1,9 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' +import { KEEPS_FOCUS_ATTRIBUTE } from '@/lib/focus-keeping-press' import { REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE, + releaseTerminalFocusForOutsidePointerDown, releaseTerminalFocusForWindowBlur, resyncTerminalFocusForWindowFocus } from './regular-terminal-focus-ownership' @@ -25,6 +27,29 @@ describe('regular terminal focus ownership', () => { document.documentElement.removeAttribute(REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE) }) + it('keeps terminal focus for a press on a focus-keeping control, and releases it elsewhere', () => { + const pane = appendPane() + const helper = appendHelper(pane) + const pill = document.createElement('div') + pill.setAttribute(KEEPS_FOCUS_ATTRIBUTE, '') + const pillButton = document.createElement('button') + pill.appendChild(pillButton) + document.body.append(pill) + helper.focus() + const press = (pointerTarget: EventTarget) => + releaseTerminalFocusForOutsidePointerDown({ + container: pane, + activeElement: document.activeElement, + pointerTarget, + syncFocused: vi.fn() + }) + + expect(press(pillButton)).toBe(false) + expect(document.activeElement).toBe(helper) + expect(press(document.body)).toBe(true) + expect(document.activeElement).not.toBe(helper) + }) + it('reclaims the exact split helper that was released, not the first one in the container', () => { // Split helpers are siblings in one container; first-match lookup would reclaim the wrong pane. const pane = appendPane() diff --git a/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.ts b/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.ts index 8ce8155a92d..a321e5be807 100644 --- a/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.ts +++ b/src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.ts @@ -1,3 +1,4 @@ +import { isFocusKeepingPress } from '@/lib/focus-keeping-press' import { isDocumentBodyOrNull, refreshTerminalImeInputContext, @@ -40,7 +41,10 @@ export function releaseTerminalFocusForOutsidePointerDown(args: { return false } - if (isNode(args.pointerTarget) && args.container.contains(args.pointerTarget)) { + if ( + (isNode(args.pointerTarget) && args.container.contains(args.pointerTarget)) || + isFocusKeepingPress(args.pointerTarget) + ) { return false } diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index 2be6f6289e7..a6a3d71a5df 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -39,6 +39,8 @@ const buttonVariants = cva( lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + // The dictation pill's round finish button. + 'icon-xs-round': "size-6 rounded-full [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', // As tall as the composer's round Send button, so swapping between them keeps the row's height. diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 367bdc35b79..19efd403491 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -16983,13 +16983,7 @@ "lost": "Das Diktat konnte weder eingefügt noch kopiert werden." }, "DictationIndicator": { - "335e1bc6cb": "Diktat stoppen", - "7f3660a7ba": "Mikrofon wird gestartet…", - "f082d0cb9d": "Wird verarbeitet…", - "3de5a129e7": "Hört zu", - "4977162383": "Zu laut", - "25f2b7a6a5": "Sprache erkannt", - "loadingModel": "Sprachmodell wird geladen…" + "4977162383": "Zu laut" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 833c167c175..54fb1287966 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17403,13 +17403,11 @@ "lost": "Dictation couldn't be inserted or copied." }, "DictationIndicator": { - "335e1bc6cb": "Stop dictation", - "7f3660a7ba": "Starting mic…", - "f082d0cb9d": "Processing…", - "3de5a129e7": "Listening", "4977162383": "Too loud", - "25f2b7a6a5": "Speaking", - "loadingModel": "Loading speech model…" + "finishing": "Finishing…", + "listening": "Listening…", + "cancel": "Cancel", + "finish": "Finish" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6034484db33..c9374061e0a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16710,13 +16710,7 @@ "lost": "No se pudo insertar ni copiar el dictado." }, "DictationIndicator": { - "335e1bc6cb": "Detener dictado", - "7f3660a7ba": "Iniciando micrófono…", - "f082d0cb9d": "Procesando…", - "3de5a129e7": "Escuchando", - "4977162383": "Volumen demasiado alto", - "25f2b7a6a5": "Hablando", - "loadingModel": "Cargando modelo de voz…" + "4977162383": "Volumen demasiado alto" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index df636858aa9..0cc2a499b89 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16935,13 +16935,7 @@ "lost": "La dictée n'a pu être ni insérée ni copiée." }, "DictationIndicator": { - "335e1bc6cb": "Arrêter la dictée", - "7f3660a7ba": "Démarrage du micro…", - "f082d0cb9d": "Traitement…", - "3de5a129e7": "Écoute en cours", - "4977162383": "Trop fort", - "25f2b7a6a5": "Vous parlez", - "loadingModel": "Chargement du modèle vocal…" + "4977162383": "Trop fort" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c313f761432..b8623ebda27 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16307,13 +16307,7 @@ "lost": "音声入力を挿入もコピーもできませんでした。" }, "DictationIndicator": { - "335e1bc6cb": "ディクテーションを停止する", - "7f3660a7ba": "マイクを起動中…", - "f082d0cb9d": "処理中…", - "3de5a129e7": "聞き取り中", - "4977162383": "音量が大きすぎます", - "25f2b7a6a5": "発話中", - "loadingModel": "音声モデルを読み込み中…" + "4977162383": "音量が大きすぎます" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a0b346b5d35..72076ace398 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16307,13 +16307,7 @@ "lost": "받아쓰기를 삽입하거나 복사할 수 없습니다." }, "DictationIndicator": { - "335e1bc6cb": "받아쓰기 중지", - "7f3660a7ba": "마이크 시작 중…", - "f082d0cb9d": "처리 중…", - "3de5a129e7": "듣는 중", - "4977162383": "음량이 너무 큽니다", - "25f2b7a6a5": "말하는 중", - "loadingModel": "음성 모델 불러오는 중…" + "4977162383": "음량이 너무 큽니다" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 98e2337c57f..140f629d103 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19268,13 +19268,7 @@ "lost": "Não foi possível inserir nem copiar o ditado." }, "DictationIndicator": { - "335e1bc6cb": "Parar ditado", - "7f3660a7ba": "Iniciando microfone…", - "f082d0cb9d": "Processando…", - "3de5a129e7": "Ouvindo", - "4977162383": "Muito alto", - "25f2b7a6a5": "Falando", - "loadingModel": "Carregando modelo de fala…" + "4977162383": "Muito alto" } }, "link": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 045d9c4b614..f717776e4f7 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -17481,13 +17481,7 @@ "lost": "Не удалось ни вставить, ни скопировать диктовку." }, "DictationIndicator": { - "335e1bc6cb": "Остановить диктовку", - "7f3660a7ba": "Включение микрофона…", - "f082d0cb9d": "Обработка…", - "3de5a129e7": "Прослушивание", - "4977162383": "Слишком громко", - "25f2b7a6a5": "Речь", - "loadingModel": "Загрузка модели речи…" + "4977162383": "Слишком громко" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 80cec422bfa..a1ed138def4 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -16967,13 +16967,7 @@ "lost": "Dikte eklenemedi ve kopyalanamadı." }, "DictationIndicator": { - "335e1bc6cb": "Dikteyi durdur", - "7f3660a7ba": "Mikrofon başlatılıyor…", - "f082d0cb9d": "İşleniyor…", - "3de5a129e7": "Dinliyor", - "4977162383": "Çok yüksek sesli", - "25f2b7a6a5": "Konuşuyor", - "loadingModel": "Konuşma modeli yükleniyor…" + "4977162383": "Çok yüksek sesli" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index b41c6393219..2b2f51a0d7c 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -16756,13 +16756,7 @@ "lost": "無法插入或複製聽寫內容。" }, "DictationIndicator": { - "335e1bc6cb": "停止聽寫", - "7f3660a7ba": "啟動麥克風...", - "f082d0cb9d": "正在處理…", - "3de5a129e7": "正在聆聽", - "4977162383": "聲音太大", - "25f2b7a6a5": "正在說話", - "loadingModel": "正在載入語音模型…" + "4977162383": "聲音太大" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d0d9d11b70d..1e0ae4fc103 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16307,13 +16307,7 @@ "lost": "无法插入或复制听写内容。" }, "DictationIndicator": { - "335e1bc6cb": "停止听写", - "7f3660a7ba": "正在启动麦克风…", - "f082d0cb9d": "正在处理…", - "3de5a129e7": "正在聆听", - "4977162383": "音量过大", - "25f2b7a6a5": "正在说话", - "loadingModel": "正在加载语音模型…" + "4977162383": "音量过大" } }, "dashboard": { diff --git a/src/renderer/src/lib/app-keyboard-router.test.ts b/src/renderer/src/lib/app-keyboard-router.test.ts index f8887db1767..5e5ef74f943 100644 --- a/src/renderer/src/lib/app-keyboard-router.test.ts +++ b/src/renderer/src/lib/app-keyboard-router.test.ts @@ -77,6 +77,22 @@ describe('app keyboard router', () => { ]) }) + it('runs a first owner ahead of owners registered before it', () => { + const seen: string[] = [] + onApp((event) => { + seen.push('overlay') + event.stopImmediatePropagation() + }) + const dictation = (event: KeyboardEvent): void => { + seen.push('dictation') + event.stopImmediatePropagation() + } + addAppKeyListener('keydown', dictation, { first: true }) + cleanups.push(() => removeAppKeyListener('keydown', dictation)) + const tail = press() + expect([...seen, ...tail]).toEqual(['dictation']) + }) + it('keeps native stop semantics between app owners', () => { const seen: string[] = [] onApp((event) => { diff --git a/src/renderer/src/lib/app-keyboard-router.ts b/src/renderer/src/lib/app-keyboard-router.ts index 261cb5e1bcd..c1e1721b815 100644 --- a/src/renderer/src/lib/app-keyboard-router.ts +++ b/src/renderer/src/lib/app-keyboard-router.ts @@ -57,13 +57,22 @@ export function replayAppKeyDown(event: KeyboardEvent): void { dispatch('keydown', event) } -export function addAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void { +/** `first` runs the owner ahead of every other one, for a key that must win wherever focus is. */ +export function addAppKeyListener( + type: AppKeyEventType, + listener: AppKeyListener, + options: { first?: boolean } = {} +): void { installAppKeyboardRouter(installedOn ?? window) const list = registrations[type] if (list.some((registration) => registration.active && registration.listener === listener)) { return } - list.push({ listener, active: true }) + if (options.first) { + list.unshift({ listener, active: true }) + } else { + list.push({ listener, active: true }) + } } export function removeAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void { diff --git a/src/renderer/src/lib/focus-keeping-press.ts b/src/renderer/src/lib/focus-keeping-press.ts new file mode 100644 index 00000000000..6ec99999288 --- /dev/null +++ b/src/renderer/src/lib/focus-keeping-press.ts @@ -0,0 +1,6 @@ +/** Marks a control whose presses leave focus where it is, so outside-press focus release skips it. */ +export const KEEPS_FOCUS_ATTRIBUTE = 'data-keeps-focus' + +export function isFocusKeepingPress(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(`[${KEEPS_FOCUS_ATTRIBUTE}]`) !== null +} diff --git a/src/renderer/src/web/preload-api/web-speech-api.ts b/src/renderer/src/web/preload-api/web-speech-api.ts index 0ef25a5bfa8..a4c0152ba1d 100644 --- a/src/renderer/src/web/preload-api/web-speech-api.ts +++ b/src/renderer/src/web/preload-api/web-speech-api.ts @@ -84,6 +84,7 @@ export function createWebSpeechApi(): Pick< | 'startDictation' | 'feedAudio' | 'stopDictation' + | 'cancelDictation' | 'onPartialTranscript' | 'onFinalTranscript' | 'onStopped' @@ -152,6 +153,10 @@ export function createWebSpeechApi(): Pick< active = dictation try { await callRuntimeResult('speech.dictation.start', { dictationId: dictation.dictationId }) + // A cancel that landed while the host was starting couldn't reach this dictation yet. + if (active !== dictation) { + void cancelOnHost(dictation) + } } catch (error) { if (active === dictation) { active = null @@ -213,6 +218,17 @@ export function createWebSpeechApi(): Pick< emit(stoppedListeners, { sessionId }) } }, + cancelDictation: async (sessionId) => { + const dictation = active?.sessionId === sessionId ? active : null + if (!dictation) { + return + } + active = null + dictation.failed = true + dictation.pending = [] + await cancelOnHost(dictation) + emit(stoppedListeners, { sessionId }) + }, onPartialTranscript: (callback) => subscribe(partialListeners, callback), onFinalTranscript: (callback) => subscribe(finalListeners, callback), onStopped: (callback) => subscribe(stoppedListeners, callback), diff --git a/src/shared/dictation-session-machine.test.ts b/src/shared/dictation-session-machine.test.ts index 7aa45c3e0f7..975f586efe1 100644 --- a/src/shared/dictation-session-machine.test.ts +++ b/src/shared/dictation-session-machine.test.ts @@ -31,4 +31,10 @@ describe('transitionDictationSession', () => { expect(restarted.phase).toBe('starting') expect(restarted.sessionId).not.toBe(listening.sessionId) }) + + it('ignores a cancel once an engine error dropped the session, so no start races its teardown', () => { + const abandoned = run('start', 'engine-ready', 'abandon') + expect(transitionDictationSession(abandoned, { type: 'cancel' })).toBe(abandoned) + expect(run('start', 'engine-ready', 'cancel')).toMatchObject({ phase: 'idle', sessionId: null }) + }) }) diff --git a/src/shared/dictation-session-machine.ts b/src/shared/dictation-session-machine.ts index 79c300c42db..22ceabc346c 100644 --- a/src/shared/dictation-session-machine.ts +++ b/src/shared/dictation-session-machine.ts @@ -20,7 +20,7 @@ export type DictationSessionEvent = | { type: 'abandon' } /** Starting failed: drop the session and show the error. */ | { type: 'fail' } - /** Drop the session without finishing it. */ + /** Drop the live session without finishing it; its words still in flight are never inserted. */ | { type: 'cancel' } /** Teardown finished; back to idle. */ | { type: 'ended' } @@ -40,6 +40,11 @@ export function isDictationUnderway(phase: DictationState): boolean { return phase === 'starting' || phase === 'listening' } +/** The pill shows from start until the last words land, including while a dropped session tears down. */ +export function isDictationPillShown(phase: DictationState): boolean { + return isDictationUnderway(phase) || phase === 'stopping' +} + /** Returns the same object when the event does not apply, so callers can skip its effects. */ export function transitionDictationSession( session: DictationSession, @@ -64,6 +69,8 @@ export function transitionDictationSession( case 'fail': return dropSession(session, 'error') case 'cancel': + // A dropped session is already tearing down; idle comes with its 'ended'. + return session.sessionId === null ? session : dropSession(session, 'idle') case 'ended': return dropSession(session, 'idle') }