diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx index db646fc7..365b07a3 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx @@ -69,7 +69,10 @@ export const ToolbarActions: React.FC = ({ 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 = ({ // 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: , toggle: true, - active: voiceState === 'recording', + active: dictatingHere, onSelect: voiceToggle, }); if (onToggleWebSearch) { diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 44c026d5..607ddff8 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -112,6 +112,7 @@ export const ChatInputView: React.FC = (p) => { return ( 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(0); const [feedback, setFeedback] = useState(null); const [partial, setPartial] = useState(null); - const [target, setTarget] = useState({ label: '', icon: null }); + const [target, setTarget] = useState({ label: '', icon: null, composerId: null }); const partialSeqRef = useRef(0); const recRef = useRef(null); const warmMicRef = useRef(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); diff --git a/frontend/src/shared/voice/voiceContext.ts b/frontend/src/shared/voice/voiceContext.ts index 6f9cab42..559d8a3f 100644 --- a/frontend/src/shared/voice/voiceContext.ts +++ b/frontend/src/shared/voice/voiceContext.ts @@ -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(NOOP);