From 511e2b00294d506adc8f68500ba68020ef425d83 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 06:42:41 -0700 Subject: [PATCH] [eric] dictation: a field that dies mid-decode drops the words instead of typing them into a stranger's box --- frontend/src/shared/voice/injectAtFocus.ts | 10 ++++++++-- frontend/src/shared/voice/injectTargetSnapshot.ts | 12 +++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/shared/voice/injectAtFocus.ts b/frontend/src/shared/voice/injectAtFocus.ts index f5c5b58c..cf33f342 100644 --- a/frontend/src/shared/voice/injectAtFocus.ts +++ b/frontend/src/shared/voice/injectAtFocus.ts @@ -1,6 +1,6 @@ import { getLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; -import { takeInjectSnapshot, setInjectSnapshot } from './injectTargetSnapshot'; +import { takeInjectSnapshot, setInjectSnapshot, isUsableTarget } from './injectTargetSnapshot'; // Dictation lands where the user's cursor actually is, like every real dictation tool: a focused // in-app field gets the text typed in (undo-friendly, fires React input events), a focused browser @@ -9,6 +9,9 @@ export type InjectTarget = 'field' | 'webview' | 'composer' | null; export function injectAtFocus(text: string): InjectTarget { const snap = takeInjectSnapshot(); + // The field the user dictated into is gone. Whatever holds focus now is a stranger's box, and + // typing their words into it is worse than dropping them, so drop them. + if (snap.targetLost && !snap.browserId) return null; const active = snap.el || (document.activeElement as HTMLElement | null); if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { try { @@ -49,8 +52,11 @@ export function injectAtFocus(text: string): InjectTarget { /** Called at press-start so the words land where the user was looking, not where focus drifted. */ export function snapshotInjectTarget(): void { + // Only a typeable element counts as "aimed at". document.activeElement is when nothing is + // focused, and storing that would read as a lost target later and swallow the composer fallback. + const active = document.activeElement as HTMLElement | null; setInjectSnapshot({ - el: document.activeElement as HTMLElement | null, + el: isUsableTarget(active) ? active : null, browserId: getLastInteractedBrowser(), }); } diff --git a/frontend/src/shared/voice/injectTargetSnapshot.ts b/frontend/src/shared/voice/injectTargetSnapshot.ts index e8538193..dc937cf0 100644 --- a/frontend/src/shared/voice/injectTargetSnapshot.ts +++ b/frontend/src/shared/voice/injectTargetSnapshot.ts @@ -21,10 +21,16 @@ export function isUsableTarget(el: HTMLElement | null): boolean { return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'WEBVIEW' || el.isContentEditable; } +export interface TakenSnapshot extends InjectSnapshot { + /** We aimed at a real field and it died mid-decode. Distinct from never having aimed anywhere. */ + targetLost: boolean; +} + /** Consumes the snapshot: reading it once is the whole contract, so a stale one can never linger. */ -export function takeInjectSnapshot(): InjectSnapshot { +export function takeInjectSnapshot(): TakenSnapshot { const snap = p_snapshot; p_snapshot = null; - if (!snap) return { el: null, browserId: null }; - return { el: isUsableTarget(snap.el) ? snap.el : null, browserId: snap.browserId }; + if (!snap) return { el: null, browserId: null, targetLost: false }; + const usable = isUsableTarget(snap.el); + return { el: usable ? snap.el : null, browserId: snap.browserId, targetLost: !!snap.el && !usable }; }