added stop button in video call

This commit is contained in:
Arjun 2026-07-04 13:24:32 +05:30
parent af24596591
commit 5b2ecc6720
5 changed files with 64 additions and 7 deletions

View file

@ -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.

View file

@ -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<void>) | 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'}

View file

@ -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({
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
Rowboat
</span>
{status !== 'listening' && (
<button
type="button"
onClick={onInterrupt}
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2.5 py-1 text-sm font-medium text-white transition-colors hover:bg-red-500"
aria-label="Stop the assistant"
title={status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
>
<Square className="h-3 w-3 fill-current" />
Stop
</button>
)}
<button
type="button"
onClick={() => setMascotVisible((v) => !v)}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Maximize2, MonitorUp, PhoneOff, User, Video, VideoOff } from 'lucide-react'
import { Maximize2, MonitorUp, PhoneOff, Square, User, Video, VideoOff } from 'lucide-react'
import { TalkingHead } from '@/components/talking-head'
@ -80,7 +80,7 @@ export function VideoPopout() {
// so the mascot still animates while the assistant speaks in the main window.
const getLevel = useCallback(() => 0.45 + 0.35 * Math.sin(performance.now() / 90), [])
const sendAction = useCallback((action: 'toggle-camera' | 'toggle-share' | 'end-call' | 'expand') => {
const sendAction = useCallback((action: 'toggle-camera' | 'toggle-share' | 'stop-speaking' | 'end-call' | 'expand') => {
void window.ipc.invoke('video:popoutAction', { action }).catch(() => {})
}, [])
@ -131,6 +131,19 @@ export function VideoPopout() {
{statusDisplay.label}
</span>
)}
{(state.status === 'speaking' || state.status === 'thinking') && (
<button
type="button"
onClick={() => sendAction('stop-speaking')}
className="absolute bottom-1 right-1.5 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white hover:bg-red-500"
style={noDragRegion}
aria-label="Stop the assistant"
title={state.status === 'speaking' ? 'Stop speaking' : 'Stop responding'}
>
<Square className="h-2.5 w-2.5 fill-current" />
Stop
</button>
)}
</div>
{/* Live caption of the in-progress utterance, floating over the tiles */}
{state.interimText && (

View file

@ -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(),
},