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
This commit is contained in:
Kelvin Amoaba
2026-11-09 07:04:54 +00:00
committed by GitHub
parent 46451538e4
commit 80deb09aa9
32 changed files with 487 additions and 187 deletions
+18 -8
View File
@@ -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<void> => {
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))
}
+4
View File
@@ -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'
+2
View File
@@ -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<boolean>
stopDictation: (sessionId?: string) => Promise<void>
/** Drops the session's audio and any text it hasn't returned yet, without transcribing the rest. */
cancelDictation: (sessionId: string) => Promise<void>
onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void
onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void
onDownloadProgress: (
+5
View File
@@ -84,6 +84,11 @@ export const speechApi = {
}
return ipcRenderer.invoke('speech:stopDictation', sessionId)
},
cancelDictation: (sessionId: string): Promise<void> => {
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 =>
@@ -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<string | null>(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<DictationPillAnchor | null>(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 <DictationIndicator captureLive={captureLive} />
return <DictationIndicator captureLive={captureLive} anchor={pillAnchor} />
}
@@ -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) => {
@@ -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<HTMLElement | null>(null)
const anchor = useMemo(
() => (pane ? { element: pane, placement: 'over' as const } : null),
[pane]
)
return (
<div ref={setPane} className="relative h-28 w-full bg-background">
{anchor ? <DictationIndicator captureLive={captureLive} anchor={anchor} /> : null}
</div>
)
}
export const listening = defineFixture({
width: 520,
store: { dictationState: 'listening' },
setup: () => publishDictationMeter({ level: 0.5, isSpeaking: true, isClipping: false }),
render: () => <PaneWithPill />
})
export const startingBeforeMic = defineFixture({
width: 520,
store: { dictationState: 'starting' },
setup: resetDictationMeter,
render: () => <PaneWithPill captureLive={false} />
})
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: () => <PaneWithPill />
})
export const tooLoud = defineFixture({
width: 520,
store: { dictationState: 'listening' },
setup: () => publishDictationMeter({ level: 1, isSpeaking: true, isClipping: true }),
render: () => <PaneWithPill />
})
export const finishing = defineFixture({
width: 520,
store: { dictationState: 'stopping' },
setup: resetDictationMeter,
render: () => <PaneWithPill />
})
@@ -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) ? <DictationPill {...props} /> : 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<number | null>(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 (
<>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{clipping
? t('auto.components.dictation.DictationIndicator.4977162383', 'Too loud')
: status}
</span>
<DictationGrapes
level={meter.level}
active={recording}
className={cn(clipping && 'text-destructive')}
/>
</>
)
}
// 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 (
<div
data-testid="dictation-indicator"
className={cn(
'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 overflow-hidden',
'border border-border bg-popover/95 text-sm text-popover-foreground shadow-floating backdrop-blur',
'transition-[width,border-radius,opacity] duration-200 ease-out motion-reduce:transition-none',
transcript
? 'w-[min(28rem,calc(100vw-2rem))] rounded-xl'
: 'max-w-[min(28rem,calc(100vw-2rem))] rounded-full',
isClipping && 'border-destructive/40 text-destructive'
'fixed z-50 flex h-10 -translate-x-1/2 items-center gap-2 rounded-full px-2 text-[13px]',
'border border-border bg-popover/95 text-popover-foreground shadow-floating backdrop-blur',
'transition-[opacity,translate] duration-180 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-opacity',
!box && 'bottom-12 left-1/2 w-[min(440px,calc(100vw-2rem))]'
)}
style={box ? { left: box.left, bottom: box.bottom, width: box.width } : undefined}
{...keepFocusProps}
>
<div className="flex h-10 items-center gap-2 px-2">
<DictationGrapes
level={dictationMeter.level}
active={dictationState !== 'stopping'}
transitioning={!isListening}
/>
<span aria-hidden className="min-w-0 truncate font-medium">
{label}
</span>
{isLoadingModel ? (
<span aria-hidden className="min-w-0 truncate text-xs text-muted-foreground">
{t(
'auto.components.dictation.DictationIndicator.loadingModel',
'Loading speech model…'
)}
</span>
) : null}
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcedLabel}
</span>
{canStop ? (
<>
<span aria-hidden className="ml-0.5 h-4 w-px shrink-0 bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={stopLabel}
className="shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
onMouseDown={(event) => event.preventDefault()}
onClick={() => dispatchDictationControl('stop')}
>
<Square className="size-3 fill-current" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="flex items-center gap-1.5">
{stopLabel}
{showShortcut ? (
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
) : null}
</TooltipContent>
</Tooltip>
</>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={cancelLabel}
onClick={() => dispatchDictationControl('cancel')}
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{cancelLabel}
</TooltipContent>
</Tooltip>
<DictationPillMeter recording={recording} status={status} />
{transcript ? (
<p className="truncate border-t border-border px-3 py-2 text-xs text-muted-foreground">
{transcript}
</p>
) : null}
<span
aria-hidden
className="flex h-6 min-w-0 flex-1 items-center justify-end overflow-hidden whitespace-nowrap text-muted-foreground [mask-image:linear-gradient(to_right,transparent,black_24px)]"
>
<span className="shrink-0">{transcript}</span>
</span>
) : (
<span aria-hidden className="min-w-0 flex-1 truncate text-muted-foreground">
{status}
</span>
)}
<span
aria-hidden
className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground"
>
{formatElapsed(seconds)}
</span>
{finishing ? (
<span
aria-hidden
className="grid size-6 shrink-0 place-items-center rounded-full bg-secondary text-muted-foreground"
>
{showSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" strokeWidth={2.5} />
)}
</span>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-xs-round"
aria-label={finishLabel}
onClick={() => dispatchDictationControl('stop')}
>
<Check className="size-3.5" strokeWidth={2.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<span className="flex items-center gap-1.5">
{finishLabel}
{!isHoldMode && shortcut.keys.length > 0 ? (
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
) : null}
</span>
</TooltipContent>
</Tooltip>
)}
</div>
)
}
@@ -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(
@@ -16,8 +16,8 @@ type ComposerInsert = (text: string) => boolean
type TerminalInsert = (text: string, paneId: number) => Promise<boolean>
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
}
@@ -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<DictationPillBox | null>(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
}
@@ -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)
@@ -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()
@@ -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
}
@@ -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.
+1 -7
View File
@@ -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": {
+4 -6
View File
@@ -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": {
+1 -7
View File
@@ -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": {
+1 -7
View File
@@ -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": {
+1 -7
View File
@@ -16307,13 +16307,7 @@
"lost": "音声入力を挿入もコピーもできませんでした。"
},
"DictationIndicator": {
"335e1bc6cb": "ディクテーションを停止する",
"7f3660a7ba": "マイクを起動中…",
"f082d0cb9d": "処理中…",
"3de5a129e7": "聞き取り中",
"4977162383": "音量が大きすぎます",
"25f2b7a6a5": "発話中",
"loadingModel": "音声モデルを読み込み中…"
"4977162383": "音量が大きすぎます"
}
},
"dashboard": {
+1 -7
View File
@@ -16307,13 +16307,7 @@
"lost": "받아쓰기를 삽입하거나 복사할 수 없습니다."
},
"DictationIndicator": {
"335e1bc6cb": "받아쓰기 중지",
"7f3660a7ba": "마이크 시작 중…",
"f082d0cb9d": "처리 중…",
"3de5a129e7": "듣는 중",
"4977162383": "음량이 너무 큽니다",
"25f2b7a6a5": "말하는 중",
"loadingModel": "음성 모델 불러오는 중…"
"4977162383": "음량이 너무 큽니다"
}
},
"dashboard": {
+1 -7
View File
@@ -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": {
+1 -7
View File
@@ -17481,13 +17481,7 @@
"lost": "Не удалось ни вставить, ни скопировать диктовку."
},
"DictationIndicator": {
"335e1bc6cb": "Остановить диктовку",
"7f3660a7ba": "Включение микрофона…",
"f082d0cb9d": "Обработка…",
"3de5a129e7": "Прослушивание",
"4977162383": "Слишком громко",
"25f2b7a6a5": "Речь",
"loadingModel": "Загрузка модели речи…"
"4977162383": "Слишком громко"
}
},
"dashboard": {
+1 -7
View File
@@ -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": {
+1 -7
View File
@@ -16756,13 +16756,7 @@
"lost": "無法插入或複製聽寫內容。"
},
"DictationIndicator": {
"335e1bc6cb": "停止聽寫",
"7f3660a7ba": "啟動麥克風...",
"f082d0cb9d": "正在處理…",
"3de5a129e7": "正在聆聽",
"4977162383": "聲音太大",
"25f2b7a6a5": "正在說話",
"loadingModel": "正在載入語音模型…"
"4977162383": "聲音太大"
}
},
"dashboard": {
+1 -7
View File
@@ -16307,13 +16307,7 @@
"lost": "无法插入或复制听写内容。"
},
"DictationIndicator": {
"335e1bc6cb": "停止听写",
"7f3660a7ba": "正在启动麦克风…",
"f082d0cb9d": "正在处理…",
"3de5a129e7": "正在聆听",
"4977162383": "音量过大",
"25f2b7a6a5": "正在说话",
"loadingModel": "正在加载语音模型…"
"4977162383": "音量过大"
}
},
"dashboard": {
@@ -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) => {
+11 -2
View File
@@ -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 {
@@ -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
}
@@ -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),
@@ -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 })
})
})
+8 -1
View File
@@ -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')
}