From 5b63c98854ef4fdfaa01ef51124e106c1faf9a0b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 16:01:05 -0700 Subject: [PATCH] [eric] dictation: the cursor wins, so a transcript lands where you clicked, not where you started (reverts ENG-176) --- frontend/src/shared/voice/injectAtFocus.ts | 11 +++++++---- .../shared/voice/injectTargetSnapshot.test.ts | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/shared/voice/injectAtFocus.ts b/frontend/src/shared/voice/injectAtFocus.ts index cf33f342..a587ddb6 100644 --- a/frontend/src/shared/voice/injectAtFocus.ts +++ b/frontend/src/shared/voice/injectAtFocus.ts @@ -9,10 +9,13 @@ 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); + // The cursor wins, not where you started. Wispr's grammar, and Eric's call: you dictate, you click + // where you want it, it lands there. This deliberately reverts the snapshot-first version, which + // pinned the text to the origin field and dropped it outright when that field went away. + // The snapshot is still the fallback for the case it was really built for: focus drifting to + // nothing typeable (a button, the body) while you were talking. + const live = document.activeElement as HTMLElement | null; + const active = isUsableTarget(live) ? live : snap.el; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { try { active.focus(); diff --git a/frontend/src/shared/voice/injectTargetSnapshot.test.ts b/frontend/src/shared/voice/injectTargetSnapshot.test.ts index 84450a0d..6fdd201e 100644 --- a/frontend/src/shared/voice/injectTargetSnapshot.test.ts +++ b/frontend/src/shared/voice/injectTargetSnapshot.test.ts @@ -8,13 +8,13 @@ const field = (connected = true) => ({ tagName: 'TEXTAREA', isConnected: connect beforeEach(() => clearInjectSnapshot()); -test('the snapshotted field wins over whatever is focused later', () => { +test('the snapshot keeps the field it was given (injectAtFocus decides precedence, not this)', () => { const a = field(); setInjectSnapshot({ el: a, browserId: null }); assert.equal(takeInjectSnapshot().el, a); }); -test('a detached field is refused so injection falls back to live focus', () => { +test('a detached field is refused, so a dead origin can never be the destination', () => { setInjectSnapshot({ el: field(false), browserId: null }); assert.equal(takeInjectSnapshot().el, null); }); @@ -37,3 +37,16 @@ test('a cancelled take leaves nothing behind', () => { clearInjectSnapshot(); assert.equal(takeInjectSnapshot().el, null); }); + +// Precedence lives in injectAtFocus, and Eric's call is Wispr's: the CURSOR wins, not the origin. +// injectAtFocus needs a live DOM, so what is pinned here is the predicate that decides whether the +// live element is allowed to win at all. Getting this wrong is how the text lands in a stranger's box. +test('a live click target only beats the origin when it is really typeable', () => { + const typeable = { tagName: 'INPUT', isConnected: true, isContentEditable: false } as unknown as HTMLElement; + const button = { tagName: 'BUTTON', isConnected: true, isContentEditable: false } as unknown as HTMLElement; + const body = { tagName: 'BODY', isConnected: true, isContentEditable: false } as unknown as HTMLElement; + assert.equal(isUsableTarget(typeable), true, 'clicking another field must take the text'); + assert.equal(isUsableTarget(button), false, 'clicking a button must NOT take the text'); + assert.equal(isUsableTarget(body), false, 'clicking empty space must NOT take the text'); + assert.equal(isUsableTarget(null), false); +});