diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md index b9cfc673..091ef415 100644 --- a/apps/x/VIDEO_MODE.md +++ b/apps/x/VIDEO_MODE.md @@ -46,7 +46,10 @@ never turns the coach on. In-call controls (identical bar on both surfaces): camera toggle (silhouette avatar while off, no webcam frames captured), screen share toggle, mascot ⇄ -"R" letter avatar, end call. Captions of the in-progress utterance and the +"R" letter avatar, end call. While the assistant is thinking or speaking, a +red **Stop** button appears on the mascot tile — it silences TTS instantly, +skips queued voice segments, and aborts the run if it's still generating +(stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the assistant's spoken line run along the bottom. Typing in the composer still works mid-call; frames ride along with typed messages too. diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4de60e33..f0017fb3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -978,6 +978,9 @@ function App() { // t0 = utterance accepted, submit = message sent, speak = first TTS // speak(). Emitted as call_turn_latency when audio actually starts. const callTurnMarksRef = useRef<{ t0: number; submit?: number; speak?: number } | null>(null) + // Late-bound handle to handleStop (defined much further down) so early + // call handlers can stop the run without reordering the component. + const stopRunRef = useRef<(() => Promise) | null>(null) // Read-aloud style: 'summary' for typed chat, forced to 'full' during a // call and restored after. Context decides — the user never picks it. const ttsModeRef = useRef<'summary' | 'full'>('summary') @@ -1250,6 +1253,21 @@ function App() { await video.startScreenShare() }, [video]) + // Interrupt the assistant: silence TTS immediately, skip anything already + // queued from the in-flight turn, and stop the run if it's still + // generating (if it already finished, stopping the speech is all there is + // to do). Wired to the Stop control next to the mascot on both surfaces. + const handleInterruptAssistant = useCallback(() => { + ttsRef.current.cancel() + setAssistantCaption('') + if (voiceSegments) { + spokenVoiceRef.current.count = voiceSegments.length + } + if (activeIsProcessing) { + void stopRunRef.current?.() + } + }, [voiceSegments, activeIsProcessing]) + // Current phase of the call (null when not in one). const videoCallStatus: 'listening' | 'thinking' | 'speaking' | null = inCall @@ -1317,13 +1335,14 @@ function App() { return window.ipc.on('video:popout-action', ({ action }) => { if (action === 'toggle-camera') handleToggleCamera() else if (action === 'toggle-share') void handleToggleScreenShare() + else if (action === 'stop-speaking') handleInterruptAssistant() else if (action === 'end-call') endCall() else if (action === 'expand') { if (video.screenState === 'live') video.stopScreenShare() setCallMinimized(false) } }) - }, [handleToggleCamera, handleToggleScreenShare, endCall, video]) + }, [handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video]) // Enter to submit voice input, Escape to cancel useEffect(() => { @@ -3027,12 +3046,18 @@ function App() { if (!runId) return setStopClickedAt(Date.now()) setIsStopping(true) + // Stopping the run must also silence it — the TTS queue holds segments + // that were already extracted from the stream and would keep playing + // long after the turn is aborted. + ttsRef.current.cancel() + setAssistantCaption('') try { await sessionChat.stop() } catch (error) { console.error('Failed to stop turn:', error) } }, [runId, sessionChat]) + stopRunRef.current = handleStop const handlePermissionResponse = useCallback(async ( toolCallId: string, @@ -6693,6 +6718,7 @@ function App() { onToggleCamera={handleToggleCamera} practiceMode={practiceMode} onMinimize={() => void handleMinimizeCall()} + onInterrupt={handleInterruptAssistant} ttsState={tts.state} getTtsLevel={tts.getLevel} status={videoCallStatus ?? 'listening'} diff --git a/apps/x/apps/renderer/src/components/video-call-view.tsx b/apps/x/apps/renderer/src/components/video-call-view.tsx index 8f95ce8c..38031b68 100644 --- a/apps/x/apps/renderer/src/components/video-call-view.tsx +++ b/apps/x/apps/renderer/src/components/video-call-view.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Minimize2, MonitorUp, PhoneOff, Presentation, User, Video, VideoOff } from 'lucide-react' +import { Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react' import { MascotFaceIcon, TalkingHead } from '@/components/talking-head' import type { TTSState } from '@/hooks/useVoiceTTS' @@ -19,6 +19,8 @@ interface VideoCallViewProps { practiceMode?: boolean /** Shrink to the floating pill without touching any devices. */ onMinimize: () => void + /** Stop the assistant: silence speech and abort the run if still going. */ + onInterrupt: () => void ttsState: TTSState /** Live TTS output level — drives the mascot's mouth animation. */ getTtsLevel: () => number @@ -52,6 +54,7 @@ export function VideoCallView({ onToggleScreenShare, practiceMode, onMinimize, + onInterrupt, ttsState, getTtsLevel, status, @@ -147,6 +150,18 @@ export function VideoCallView({ Rowboat + {status !== 'listening' && ( + + )} + )} {/* Live caption of the in-progress utterance, floating over the tiles */} {state.interimText && ( diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index e54df4e8..a6784839 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1451,7 +1451,7 @@ const ipcSchemas = { // main app window (handled in the main process). 'video:popoutAction': { req: z.object({ - action: z.enum(['toggle-camera', 'toggle-share', 'end-call', 'expand']), + action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']), }), res: z.object({}), }, @@ -1469,7 +1469,7 @@ const ipcSchemas = { // Push channel: main → app window with a popout control-bar action. 'video:popout-action': { req: z.object({ - action: z.enum(['toggle-camera', 'toggle-share', 'end-call', 'expand']), + action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']), }), res: z.null(), },