[eric] dictation: only the composer the transcript lands in shows Stop dictation, not every chat at once (ENG-239)

This commit is contained in:
ciregenz
2026-08-11 01:48:40 -07:00
parent 8eee7ee16a
commit 6afa7a1a1d
4 changed files with 20 additions and 11 deletions
@@ -69,7 +69,10 @@ export const ToolbarActions: React.FC<Props> = ({
elementSelection.setSelectMode(true);
}
};
const { state: voiceState, toggle: voiceToggle } = useVoice();
const { state: voiceState, toggle: voiceToggle, target: voiceTarget } = useVoice();
// Only the composer the dictation is actually landing in shows the stop affordance; every chat
// used to flip to "Stop dictation" at once because this read only the global state (ENG-239).
const dictatingHere = voiceState === 'recording' && voiceTarget.composerId === (sessionId ?? 'dashboard');
const plusItems: PlusMenuItem[] = [];
plusItems.push({
key: 'attach',
@@ -80,10 +83,10 @@ export const ToolbarActions: React.FC<Props> = ({
// A menu click can't be held, so this entry always toggles regardless of the hold-to-talk setting.
plusItems.push({
key: 'dictate',
label: voiceState === 'recording' ? 'Stop dictation' : 'Dictate',
label: dictatingHere ? 'Stop dictation' : 'Dictate',
icon: <MicNoneOutlinedIcon sx={{ fontSize: 17 }} />,
toggle: true,
active: voiceState === 'recording',
active: dictatingHere,
onSelect: voiceToggle,
});
if (onToggleWebSearch) {
@@ -112,6 +112,7 @@ export const ChatInputView: React.FC<Props> = (p) => {
return (
<Box
ref={p.containerRef}
data-osw-composer={p.sessionId ?? 'dashboard'}
onDragOver={p.handleDragOver}
onDragLeave={p.handleDragLeave}
onDrop={p.handleDrop}
+10 -5
View File
@@ -57,6 +57,10 @@ export interface VoiceFeedback {
export interface InjectTargetInfo {
label: string;
icon: string | null;
// Which composer owns the dictation right now (its data-osw-composer id), or null when the
// target is not a composer. Lets each composer show "Stop dictation" only when IT is the target
// instead of every chat lighting up at once (ENG-239).
composerId: string | null;
}
function browserTargetInfo(): InjectTargetInfo {
@@ -64,18 +68,19 @@ function browserTargetInfo(): InjectTargetInfo {
const card = browserId ? store.getState().dashboardLayout.browserCards[browserId] : undefined;
const tab = card?.tabs?.find((t) => t.id === card.activeTabId);
const host = (() => { try { return tab?.url ? new URL(tab.url).hostname : null; } catch { return null; } })();
return { label: host || 'browser page', icon: tab?.favicon || null };
return { label: host || 'browser page', icon: tab?.favicon || null, composerId: null };
}
export function describeInjectTarget(): InjectTargetInfo {
const a = document.activeElement as HTMLElement | null;
const composerId = a?.closest?.('[data-osw-composer]')?.getAttribute('data-osw-composer') ?? null;
if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)) {
const hint = a.getAttribute('placeholder') || a.getAttribute('aria-label');
return { label: hint ? hint.slice(0, 30) : 'text field', icon: null };
return { label: hint ? hint.slice(0, 30) : 'text field', icon: null, composerId };
}
if (a && a.tagName === 'WEBVIEW') return browserTargetInfo();
if (getLastInteractedBrowser()) return browserTargetInfo();
return { label: 'chat composer', icon: null };
return { label: 'chat composer', icon: null, composerId };
}
// Context hint for the polisher: what the user is dictating into (a field label, a page title), so
@@ -112,7 +117,7 @@ export function useVoiceDictation() {
const [pct, setPct] = useState<number>(0);
const [feedback, setFeedback] = useState<VoiceFeedback | null>(null);
const [partial, setPartial] = useState<VoicePartial | null>(null);
const [target, setTarget] = useState<InjectTargetInfo>({ label: '', icon: null });
const [target, setTarget] = useState<InjectTargetInfo>({ label: '', icon: null, composerId: null });
const partialSeqRef = useRef<number>(0);
const recRef = useRef<Recorder | null>(null);
const warmMicRef = useRef<WarmMic | null>(null);
@@ -436,7 +441,7 @@ export function useVoiceDictation() {
// The target chip tracks focus LIVE while recording: clicking into a field mid-dictation retargets
// injection (by design), and the chip must tell that truth as it happens.
useEffect(() => {
if (state !== 'recording') { setTarget({ label: '', icon: null }); return undefined; }
if (state !== 'recording') { setTarget({ label: '', icon: null, composerId: null }); return undefined; }
setTarget(describeInjectTarget());
const onFocus = (): void => setTarget(describeInjectTarget());
window.addEventListener('focusin', onFocus, true);
+3 -3
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext } from 'react';
import { VoiceState, VoiceFeedback, VoicePartial } from './useVoiceDictation';
import { InjectTargetInfo, VoiceState, VoiceFeedback, VoicePartial } from './useVoiceDictation';
// The context lives below both the provider and the overlay so neither imports the other
// (VoiceDictationContext renders VoiceOverlay; both reach down here instead of sideways).
@@ -11,7 +11,7 @@ export interface VoiceContextValue {
feedback: VoiceFeedback | null;
partial: VoicePartial | null;
// Where the transcript will land right now, in user words plus the surface's icon.
target: { label: string; icon: string | null };
target: InjectTargetInfo;
toggle: () => void;
// Mic-button press semantics that respect the hold/toggle setting: press starts (or toggles),
// release stops only in hold mode. Buttons wire onPointerDown/Up to these and stay mode-agnostic.
@@ -25,7 +25,7 @@ export interface VoiceContextValue {
}
const NOOP_REF = { current: 0 };
const NOOP: VoiceContextValue = { state: 'idle', lastText: '', error: null, pct: 0, feedback: null, partial: null, target: { label: '', icon: null }, toggle: () => {}, pressStart: () => {}, pressEnd: () => {}, confirmRecording: () => {}, cancelRecording: () => {}, holdMode: true, volumeRef: NOOP_REF };
const NOOP: VoiceContextValue = { state: 'idle', lastText: '', error: null, pct: 0, feedback: null, partial: null, target: { label: '', icon: null, composerId: null }, toggle: () => {}, pressStart: () => {}, pressEnd: () => {}, confirmRecording: () => {}, cancelRecording: () => {}, holdMode: true, volumeRef: NOOP_REF };
export const VoiceContext = createContext<VoiceContextValue>(NOOP);