Add in-call mic mute: pauses all input (mic audio + frame capture)

A mute button on both call surfaces (full-screen call and floating
popout) that pauses everything going to the assistant while keeping the
call alive — for stepping away to talk to someone in the room without
ending and restarting the call.

- Mic audio stops reaching Deepgram (user mute OR'd into the existing
  thinking/speaking setPaused; KeepAlives keep the socket warm so
  unmute is instant)
- Camera/screen frames stop being sampled (new setCapturePaused in
  useVideoMode); collectFrames() returns nothing while muted, so typed
  messages during a mute carry no frames either
- Devices stay acquired for instant resume; mute resets at call
  start/end; assistant output is unaffected (Stop handles that)
- Honest UI: "Muted" status chip replaces the green "Listening" pulse,
  muted badge on the user tile, pill's share badge flips to "Sharing
  paused"; new toggle-mic popout action + micMuted in popout state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-07-06 00:28:34 +05:30
parent 1c380c33ed
commit adb8b16a3c
7 changed files with 147 additions and 23 deletions

View file

@ -44,9 +44,19 @@ starts anyway as a voice call, with a toast linking to System Settings.
Practice/coaching is always an explicit choice — expanding to full screen Practice/coaching is always an explicit choice — expanding to full screen
never turns the coach on. never turns the coach on.
In-call controls (identical bar on both surfaces): camera toggle (silhouette In-call controls (identical bar on both surfaces): mic mute, camera toggle
avatar while off, no webcam frames captured), screen share toggle, mascot ⇄ (silhouette avatar while off, no webcam frames captured), screen share
"R" letter avatar, end call. While the assistant is thinking or speaking, a toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input
pause**, not just audio — mic audio stops reaching Deepgram
(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause)
AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`;
`collectFrames()` returns nothing while muted, so typed messages carry no
frames either), letting the user talk to someone in the room without the
assistant listening in. Devices stay acquired for instant unmute (camera
light and macOS share indicator stay on — the pill's share badge switches to
"Sharing paused"), the status chip shows "Muted" instead of "Listening",
and assistant output is unaffected (in-flight speech keeps playing; Stop
handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a
red **Stop** button appears on the mascot tile — it silences TTS instantly, 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 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 (stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the

View file

@ -395,6 +395,7 @@ let lastVideoPopoutState: {
ttsState: 'idle' | 'synthesizing' | 'speaking'; ttsState: 'idle' | 'synthesizing' | 'speaking';
status: 'listening' | 'thinking' | 'speaking' | null; status: 'listening' | 'thinking' | 'speaking' | null;
cameraOn: boolean; cameraOn: boolean;
micMuted: boolean;
screenSharing: boolean; screenSharing: boolean;
interimText: string | null; interimText: string | null;
} | null = null; } | null = null;

View file

@ -1061,6 +1061,11 @@ function App() {
const inCallRef = useRef(false) const inCallRef = useRef(false)
// User explicitly shrank the full-screen call to the floating pill. // User explicitly shrank the full-screen call to the floating pill.
const [callMinimized, setCallMinimized] = useState(false) const [callMinimized, setCallMinimized] = useState(false)
// In-call mute: a full input pause, not just audio — mic audio stops
// reaching Deepgram AND camera/screen frame capture stops, so nothing said
// or shown while muted ever reaches the assistant. Output is untouched
// (in-flight speech keeps playing; the Stop control handles that).
const [micMuted, setMicMuted] = useState(false)
// Practice preset: adds the coaching persona to the system prompt. // Practice preset: adds the coaching persona to the system prompt.
const [practiceMode, setPracticeMode] = useState(false) const [practiceMode, setPracticeMode] = useState(false)
const practiceModeRef = useRef(false) const practiceModeRef = useRef(false)
@ -1200,6 +1205,7 @@ function App() {
setPracticeMode(preset === 'practice') setPracticeMode(preset === 'practice')
practiceModeRef.current = preset === 'practice' practiceModeRef.current = preset === 'practice'
setMicMuted(false)
// Pill-first presets start minimized; face-to-face presets start expanded. // Pill-first presets start minimized; face-to-face presets start expanded.
setCallMinimized(preset === 'voice' || preset === 'share') setCallMinimized(preset === 'voice' || preset === 'share')
inCallRef.current = true inCallRef.current = true
@ -1217,17 +1223,26 @@ function App() {
video.stop() video.stop()
setPracticeMode(false) setPracticeMode(false)
practiceModeRef.current = false practiceModeRef.current = false
setMicMuted(false)
setCallMinimized(false) setCallMinimized(false)
inCallRef.current = false inCallRef.current = false
setInCall(false) setInCall(false)
}, [video]) }, [video])
// During a call, mute the mic while the assistant is thinking or speaking // During a call, mute the mic while the assistant is thinking or speaking
// so its own TTS (or a half-turn) never gets transcribed back at it. // so its own TTS (or a half-turn) never gets transcribed back at it — and
// whenever the user muted themselves.
useEffect(() => { useEffect(() => {
if (!inCall) return if (!inCall) return
voiceRef.current.setPaused(activeIsProcessing || tts.state !== 'idle') voiceRef.current.setPaused(micMuted || activeIsProcessing || tts.state !== 'idle')
}, [inCall, activeIsProcessing, tts.state]) }, [inCall, micMuted, activeIsProcessing, tts.state])
// The user-mute half that lives in the video pipeline: stop sampling
// camera/screen frames while muted (see useVideoMode.setCapturePaused).
const setCapturePaused = video.setCapturePaused
useEffect(() => {
setCapturePaused(micMuted)
}, [micMuted, setCapturePaused])
// Screen sharing: frames of the shared screen ride along with each message // Screen sharing: frames of the shared screen ride along with each message
// next to the webcam frames. The surface change (full screen → pill) falls // next to the webcam frames. The surface change (full screen → pill) falls
@ -1248,6 +1263,14 @@ function App() {
void video.setCameraEnabled(!video.cameraOn) void video.setCameraEnabled(!video.cameraOn)
}, [video]) }, [video])
// Zoom-style mute button, except it pauses ALL input (mic + frames) so the
// user can talk to someone in the room without the assistant listening in.
// Devices stay acquired (camera light and share indicator stay on) so
// unmuting is instant.
const handleToggleMic = useCallback(() => {
setMicMuted((m) => !m)
}, [])
// Minimizing the full-screen call drops you back to working — and the pill // Minimizing the full-screen call drops you back to working — and the pill
// exists to work *together*, so sharing starts automatically (the symmetric // exists to work *together*, so sharing starts automatically (the symmetric
// twin of expand, which stops it). If capture fails (permission), the call // twin of expand, which stops it). If capture fails (permission), the call
@ -1327,11 +1350,12 @@ function App() {
ttsState: tts.state, ttsState: tts.state,
status: videoCallStatus, status: videoCallStatus,
cameraOn: video.cameraOn, cameraOn: video.cameraOn,
micMuted,
screenSharing: video.screenState === 'live', screenSharing: video.screenState === 'live',
interimText: voice.interimText || null, interimText: voice.interimText || null,
}) })
.catch(() => {}) .catch(() => {})
}, [inCall, tts.state, videoCallStatus, video.cameraOn, video.screenState, voice.interimText]) }, [inCall, tts.state, videoCallStatus, video.cameraOn, micMuted, video.screenState, voice.interimText])
// Execute popout control-bar actions (the popout window has no access to // Execute popout control-bar actions (the popout window has no access to
// the call's mic/camera/capture — they live here). 'expand' goes full // the call's mic/camera/capture — they live here). 'expand' goes full
@ -1339,7 +1363,8 @@ function App() {
// process already refocused the app window. // process already refocused the app window.
useEffect(() => { useEffect(() => {
return window.ipc.on('video:popout-action', ({ action }) => { return window.ipc.on('video:popout-action', ({ action }) => {
if (action === 'toggle-camera') handleToggleCamera() if (action === 'toggle-mic') handleToggleMic()
else if (action === 'toggle-camera') handleToggleCamera()
else if (action === 'toggle-share') void handleToggleScreenShare() else if (action === 'toggle-share') void handleToggleScreenShare()
else if (action === 'stop-speaking') handleInterruptAssistant() else if (action === 'stop-speaking') handleInterruptAssistant()
else if (action === 'end-call') endCall() else if (action === 'end-call') endCall()
@ -1348,7 +1373,7 @@ function App() {
setCallMinimized(false) setCallMinimized(false)
} }
}) })
}, [handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video]) }, [handleToggleMic, handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video])
// Enter to submit voice input, Escape to cancel // Enter to submit voice input, Escape to cancel
useEffect(() => { useEffect(() => {
@ -6835,6 +6860,8 @@ function App() {
onToggleScreenShare={handleToggleScreenShare} onToggleScreenShare={handleToggleScreenShare}
cameraOn={video.cameraOn} cameraOn={video.cameraOn}
onToggleCamera={handleToggleCamera} onToggleCamera={handleToggleCamera}
micMuted={micMuted}
onToggleMic={handleToggleMic}
practiceMode={practiceMode} practiceMode={practiceMode}
onMinimize={() => void handleMinimizeCall()} onMinimize={() => void handleMinimizeCall()}
onInterrupt={handleInterruptAssistant} onInterrupt={handleInterruptAssistant}

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react' import { Mic, MicOff, Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react'
import { MascotFaceIcon, TalkingHead } from '@/components/talking-head' import { MascotFaceIcon, TalkingHead } from '@/components/talking-head'
import type { TTSState } from '@/hooks/useVoiceTTS' import type { TTSState } from '@/hooks/useVoiceTTS'
@ -12,6 +12,9 @@ interface VideoCallViewProps {
streamRef: React.MutableRefObject<MediaStream | null> streamRef: React.MutableRefObject<MediaStream | null>
cameraOn: boolean cameraOn: boolean
onToggleCamera: () => void onToggleCamera: () => void
/** User mute = full input pause: no mic audio AND no frame capture. */
micMuted: boolean
onToggleMic: () => void
/** Starting a share collapses this view into the floating popout (the /** Starting a share collapses this view into the floating popout (the
* surface is derived from devices see App.tsx). */ * surface is derived from devices see App.tsx). */
onToggleScreenShare: () => void onToggleScreenShare: () => void
@ -51,6 +54,8 @@ export function VideoCallView({
streamRef, streamRef,
cameraOn, cameraOn,
onToggleCamera, onToggleCamera,
micMuted,
onToggleMic,
onToggleScreenShare, onToggleScreenShare,
practiceMode, practiceMode,
onMinimize, onMinimize,
@ -128,6 +133,12 @@ export function VideoCallView({
<span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white"> <span className="absolute bottom-3 left-3 rounded-md bg-black/50 px-2 py-0.5 text-sm text-white">
You You
</span> </span>
{micMuted && (
<span className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-md bg-red-600/90 px-2 py-0.5 text-sm font-medium text-white">
<MicOff className="h-3.5 w-3.5" />
Muted nothing is heard or captured
</span>
)}
</div> </div>
{/* Assistant */} {/* Assistant */}
@ -185,9 +196,34 @@ export function VideoCallView({
{/* Control bar */} {/* Control bar */}
<div className="flex items-center justify-center gap-4 pb-5"> <div className="flex items-center justify-center gap-4 pb-5">
<span className="flex items-center gap-2 rounded-full bg-neutral-800 px-3 py-1.5 text-xs font-medium text-white/90"> <span className="flex items-center gap-2 rounded-full bg-neutral-800 px-3 py-1.5 text-xs font-medium text-white/90">
<span className={cn('block h-2 w-2 rounded-full', STATUS_DISPLAY[status].dotClass)} /> {/* Muted overrides "Listening" the green pulse would be a lie.
{STATUS_DISPLAY[status].label} Thinking/speaking still show: output continues while muted. */}
{micMuted && status === 'listening' ? (
<>
<span className="block h-2 w-2 rounded-full bg-red-500" />
Muted
</>
) : (
<>
<span className={cn('block h-2 w-2 rounded-full', STATUS_DISPLAY[status].dotClass)} />
{STATUS_DISPLAY[status].label}
</>
)}
</span> </span>
<button
type="button"
onClick={onToggleMic}
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full transition-colors',
micMuted
? 'bg-red-600 text-white hover:bg-red-500'
: 'bg-neutral-800 text-white/90 hover:bg-neutral-700'
)}
aria-label={micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
title={micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
>
{micMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
<button <button
type="button" type="button"
onClick={onToggleCamera} onClick={onToggleCamera}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Maximize2, MonitorUp, PhoneOff, Square, User, Video, VideoOff } from 'lucide-react' import { Maximize2, Mic, MicOff, MonitorUp, PhoneOff, Square, User, Video, VideoOff } from 'lucide-react'
import { TalkingHead } from '@/components/talking-head' import { TalkingHead } from '@/components/talking-head'
@ -7,6 +7,8 @@ type PopoutState = {
ttsState: 'idle' | 'synthesizing' | 'speaking' ttsState: 'idle' | 'synthesizing' | 'speaking'
status: 'listening' | 'thinking' | 'speaking' | null status: 'listening' | 'thinking' | 'speaking' | null
cameraOn: boolean cameraOn: boolean
/** User mute = full input pause: no mic audio AND no frame capture. */
micMuted: boolean
screenSharing: boolean screenSharing: boolean
interimText: string | null interimText: string | null
} }
@ -34,7 +36,7 @@ export function VideoPopout() {
// Camera defaults OFF: guessing "on" would flash the user's video for a // Camera defaults OFF: guessing "on" would flash the user's video for a
// beat before the real state arrives — which reads as a bug. The true // beat before the real state arrives — which reads as a bug. The true
// state is fetched immediately below. // state is fetched immediately below.
const [state, setState] = useState<PopoutState>({ ttsState: 'idle', status: null, cameraOn: false, screenSharing: false, interimText: null }) const [state, setState] = useState<PopoutState>({ ttsState: 'idle', status: null, cameraOn: false, micMuted: false, screenSharing: false, interimText: null })
const videoRef = useRef<HTMLVideoElement | null>(null) const videoRef = useRef<HTMLVideoElement | null>(null)
useEffect(() => { useEffect(() => {
@ -80,7 +82,7 @@ export function VideoPopout() {
// so the mascot still animates while the assistant speaks in the main window. // 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 getLevel = useCallback(() => 0.45 + 0.35 * Math.sin(performance.now() / 90), [])
const sendAction = useCallback((action: 'toggle-camera' | 'toggle-share' | 'stop-speaking' | 'end-call' | 'expand') => { const sendAction = useCallback((action: 'toggle-mic' | 'toggle-camera' | 'toggle-share' | 'stop-speaking' | 'end-call' | 'expand') => {
void window.ipc.invoke('video:popoutAction', { action }).catch(() => {}) void window.ipc.invoke('video:popoutAction', { action }).catch(() => {})
}, []) }, [])
@ -112,11 +114,18 @@ export function VideoPopout() {
You You
</span> </span>
{/* Persistent consent badge the user must always be able to see {/* Persistent consent badge the user must always be able to see
at a glance that their screen is going out. */} at a glance that their screen is going out. Muted pauses frame
capture while keeping the share stream open, so say so. */}
{state.screenSharing && ( {state.screenSharing && (
<span className="absolute left-1.5 top-1.5 flex items-center gap-1 rounded-full bg-sky-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white"> <span className="absolute left-1.5 top-1.5 flex items-center gap-1 rounded-full bg-sky-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
<span className="block h-1.5 w-1.5 animate-pulse rounded-full bg-white" /> <span className={`block h-1.5 w-1.5 rounded-full bg-white ${state.micMuted ? '' : 'animate-pulse'}`} />
Sharing screen {state.micMuted ? 'Sharing paused' : 'Sharing screen'}
</span>
)}
{state.micMuted && (
<span 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">
<MicOff className="h-2.5 w-2.5" />
Muted
</span> </span>
)} )}
</div> </div>
@ -127,8 +136,18 @@ export function VideoPopout() {
</span> </span>
{statusDisplay && ( {statusDisplay && (
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white"> <span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
<span className={`block h-1.5 w-1.5 rounded-full ${statusDisplay.dotClass}`} /> {/* Muted overrides "Listening" — the green pulse would be a lie. */}
{statusDisplay.label} {state.micMuted && state.status === 'listening' ? (
<>
<span className="block h-1.5 w-1.5 rounded-full bg-red-500" />
Muted
</>
) : (
<>
<span className={`block h-1.5 w-1.5 rounded-full ${statusDisplay.dotClass}`} />
{statusDisplay.label}
</>
)}
</span> </span>
)} )}
{(state.status === 'speaking' || state.status === 'thinking') && ( {(state.status === 'speaking' || state.status === 'thinking') && (
@ -157,6 +176,19 @@ export function VideoPopout() {
{/* Control bar — actions execute in the main app window */} {/* Control bar — actions execute in the main app window */}
<div className="flex h-7 shrink-0 items-center justify-center gap-2" style={noDragRegion}> <div className="flex h-7 shrink-0 items-center justify-center gap-2" style={noDragRegion}>
<button
type="button"
onClick={() => sendAction('toggle-mic')}
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
state.micMuted
? 'bg-red-600 text-white hover:bg-red-500'
: 'bg-neutral-700 text-white/90 hover:bg-neutral-600'
}`}
aria-label={state.micMuted ? 'Unmute' : 'Mute (pauses mic and frame capture)'}
title={state.micMuted ? 'Unmute' : 'Mute — pauses your mic and all frame capture'}
>
{state.micMuted ? <MicOff className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
</button>
<button <button
type="button" type="button"
onClick={() => sendAction('toggle-camera')} onClick={() => sendAction('toggle-camera')}

View file

@ -154,6 +154,10 @@ export function useVideoMode() {
// Camera can be turned off mid-session (Meet-style) while the mode — and // Camera can be turned off mid-session (Meet-style) while the mode — and
// any screen share — keeps running. Resets to on for the next session. // any screen share — keeps running. Resets to on for the next session.
const [cameraOn, setCameraOn] = useState(true); const [cameraOn, setCameraOn] = useState(true);
// In-call mute pauses capture entirely: nothing lands in the ring buffers
// and collectFrames() returns nothing, so a muted stretch can never ride
// along with a later message. Streams stay open for instant resume.
const capturePausedRef = useRef(false);
const cameraPipeRef = useRef<CapturePipe>(emptyPipe()); const cameraPipeRef = useRef<CapturePipe>(emptyPipe());
const screenPipeRef = useRef<CapturePipe>(emptyPipe()); const screenPipeRef = useRef<CapturePipe>(emptyPipe());
// Stable stream refs for preview components (<video srcObject>). // Stable stream refs for preview components (<video srcObject>).
@ -165,13 +169,19 @@ export function useVideoMode() {
screenStateRef.current = screenState; screenStateRef.current = screenState;
const captureCameraFrame = useCallback(() => { const captureCameraFrame = useCallback(() => {
if (capturePausedRef.current) return;
capturePipeFrame(cameraPipeRef.current, CAMERA_FRAME_WIDTH, CAMERA_JPEG_QUALITY); capturePipeFrame(cameraPipeRef.current, CAMERA_FRAME_WIDTH, CAMERA_JPEG_QUALITY);
}, []); }, []);
const captureScreenFrame = useCallback(() => { const captureScreenFrame = useCallback(() => {
if (capturePausedRef.current) return;
capturePipeFrame(screenPipeRef.current, SCREEN_FRAME_WIDTH, SCREEN_JPEG_QUALITY); capturePipeFrame(screenPipeRef.current, SCREEN_FRAME_WIDTH, SCREEN_JPEG_QUALITY);
}, []); }, []);
const setCapturePaused = useCallback((paused: boolean) => {
capturePausedRef.current = paused;
}, []);
const stopScreenShare = useCallback(() => { const stopScreenShare = useCallback(() => {
teardownPipe(screenPipeRef.current); teardownPipe(screenPipeRef.current);
screenStreamRef.current = null; screenStreamRef.current = null;
@ -183,6 +193,7 @@ export function useVideoMode() {
streamRef.current = null; streamRef.current = null;
setState('idle'); setState('idle');
setCameraOn(true); setCameraOn(true);
capturePausedRef.current = false;
stopScreenShare(); stopScreenShare();
}, [stopScreenShare]); }, [stopScreenShare]);
@ -295,6 +306,9 @@ export function useVideoMode() {
*/ */
const collectFrames = useCallback((): CapturedVideoFrame[] => { const collectFrames = useCallback((): CapturedVideoFrame[] => {
if (stateRef.current !== 'live') return []; if (stateRef.current !== 'live') return [];
// Muted: no frames at all — not even pre-mute buffered ones — so a
// typed message during a mute carries nothing captured around it.
if (capturePausedRef.current) return [];
// Grab a frame right now so the message always includes the moment of send. // Grab a frame right now so the message always includes the moment of send.
captureCameraFrame(); captureCameraFrame();
const frames = drainPipe(cameraPipeRef.current, MAX_CAMERA_FRAMES_PER_MESSAGE, 'camera'); const frames = drainPipe(cameraPipeRef.current, MAX_CAMERA_FRAMES_PER_MESSAGE, 'camera');
@ -308,5 +322,5 @@ export function useVideoMode() {
// Release the camera/screen if the component unmounts with video mode on. // Release the camera/screen if the component unmounts with video mode on.
useEffect(() => stop, [stop]); useEffect(() => stop, [stop]);
return { state, screenState, cameraOn, streamRef, screenStreamRef, start, stop, startScreenShare, stopScreenShare, setCameraEnabled, collectFrames }; return { state, screenState, cameraOn, streamRef, screenStreamRef, start, stop, startScreenShare, stopScreenShare, setCameraEnabled, setCapturePaused, collectFrames };
} }

View file

@ -1465,6 +1465,8 @@ const ipcSchemas = {
ttsState: z.enum(['idle', 'synthesizing', 'speaking']), ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(), status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(), cameraOn: z.boolean(),
// User mute: mic audio and frame capture are both paused.
micMuted: z.boolean(),
screenSharing: z.boolean(), screenSharing: z.boolean(),
// Live transcript of the in-progress utterance. // Live transcript of the in-progress utterance.
interimText: z.string().nullable(), interimText: z.string().nullable(),
@ -1483,6 +1485,7 @@ const ipcSchemas = {
ttsState: z.enum(['idle', 'synthesizing', 'speaking']), ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(), status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(), cameraOn: z.boolean(),
micMuted: z.boolean(),
screenSharing: z.boolean(), screenSharing: z.boolean(),
interimText: z.string().nullable(), interimText: z.string().nullable(),
}) })
@ -1494,7 +1497,7 @@ const ipcSchemas = {
// main app window (handled in the main process). // main app window (handled in the main process).
'video:popoutAction': { 'video:popoutAction': {
req: z.object({ req: z.object({
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']), action: z.enum(['toggle-mic', 'toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
}), }),
res: z.object({}), res: z.object({}),
}, },
@ -1504,6 +1507,7 @@ const ipcSchemas = {
ttsState: z.enum(['idle', 'synthesizing', 'speaking']), ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(), status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(), cameraOn: z.boolean(),
micMuted: z.boolean(),
screenSharing: z.boolean(), screenSharing: z.boolean(),
interimText: z.string().nullable(), interimText: z.string().nullable(),
}), }),
@ -1512,7 +1516,7 @@ const ipcSchemas = {
// Push channel: main → app window with a popout control-bar action. // Push channel: main → app window with a popout control-bar action.
'video:popout-action': { 'video:popout-action': {
req: z.object({ req: z.object({
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']), action: z.enum(['toggle-mic', 'toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
}), }),
res: z.null(), res: z.null(),
}, },