mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] dictation: only the composer the transcript lands in shows Stop dictation, not every chat at once (ENG-239)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user