diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2dc4aadf..49578d4a 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1713,6 +1713,21 @@ export function setupIpcHandlers() { return { granted: false }; } }, + 'voice:ensureCameraAccess': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('camera'); + console.log('[video] Camera permission status:', status); + if (status === 'granted') return { granted: true }; + // Same flow as the microphone: settle the native TCC prompt before the + // renderer's getUserMedia so the first video click doesn't silently fail. + try { + const granted = await systemPreferences.askForMediaAccess('camera'); + console.log('[video] Camera permission after prompt:', granted); + return { granted }; + } catch { + return { granted: false }; + } + }, // Live-note handlers 'live-note:run': async (_event, args) => { const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7398aa92..9ef8995f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -118,8 +118,10 @@ import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' +import { useVideoMode } from '@/hooks/useVideoMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { TalkingHeadOverlay } from '@/components/talking-head' +import { VideoPreviewOverlay } from '@/components/video-preview-overlay' import { ProductTour, type TourNavTarget } from '@/components/product-tour' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' @@ -1010,6 +1012,23 @@ function App() { const voiceRef = useRef(voice) voiceRef.current = voice + // Video chat mode: while on, the webcam runs and frames are attached to + // every outgoing message so the assistant can see the user. + const video = useVideoMode() + const [videoEnabled, setVideoEnabled] = useState(false) + const videoEnabledRef = useRef(false) + const handleToggleVideo = useCallback(async () => { + if (videoEnabledRef.current) { + video.stop() + videoEnabledRef.current = false + setVideoEnabled(false) + } else { + const ok = await video.start() + videoEnabledRef.current = ok + setVideoEnabled(ok) + } + }, [video]) + const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined) const meetingTranscription = useMeetingTranscription(() => { handleToggleMeetingRef.current?.() @@ -2608,15 +2627,28 @@ function App() { setMessage('') + // Video chat mode: drain the webcam frames buffered since the last send + // so they ride along with this message as inline image parts. + const videoFrames = videoEnabledRef.current ? video.collectFrames() : [] + const userMessageId = `user-${Date.now()}` - const displayAttachments: ChatMessage['attachments'] = hasAttachments - ? stagedAttachments.map((attachment) => ({ - path: attachment.path, - filename: attachment.filename, - mimeType: attachment.mimeType, - size: attachment.size, - thumbnailUrl: attachment.thumbnailUrl, - })) + const displayAttachments: ChatMessage['attachments'] = hasAttachments || videoFrames.length > 0 + ? [ + ...stagedAttachments.map((attachment) => ({ + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + thumbnailUrl: attachment.thumbnailUrl, + })), + ...videoFrames.map((frame, index) => ({ + path: '', + filename: `camera-frame-${index + 1}.jpg`, + mimeType: frame.mediaType, + thumbnailUrl: frame.dataUrl, + isVideoFrame: true, + })), + ] : undefined setConversation((prev) => [...prev, { id: userMessageId, @@ -2668,6 +2700,7 @@ function App() { ...(ttsEnabledRef.current ? { voiceOutput: ttsModeRef.current } : {}), ...(searchEnabled ? { searchEnabled: true } : {}), ...(codeMode ? { codeMode } : {}), + ...(videoEnabledRef.current ? { videoMode: true } : {}), }, }, }, @@ -2678,7 +2711,7 @@ function App() { middlePane: middlePane ?? { kind: 'empty' as const }, }) - if (hasAttachments || hasMentions) { + if (hasAttachments || hasMentions || videoFrames.length > 0) { type ContentPart = | { type: 'text'; text: string } | { @@ -2689,6 +2722,13 @@ function App() { size?: number lineNumber?: number } + | { + type: 'image' + data: string + mediaType: string + source: 'camera' + capturedAt: string + } const contentParts: ContentPart[] = [] @@ -2720,6 +2760,16 @@ function App() { titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? '' } + for (const frame of videoFrames) { + contentParts.push({ + type: 'image', + data: frame.data, + mediaType: frame.mediaType, + source: 'camera', + capturedAt: frame.capturedAt, + }) + } + const middlePaneContext = await buildMiddlePaneContext() await window.ipc.invoke('sessions:sendMessage', { sessionId: currentRunId, @@ -6337,6 +6387,8 @@ function App() { onTtsModeChange={isActive ? handleTtsModeChange : undefined} ttsAvatarEnabled={ttsAvatarEnabled} onToggleTtsAvatar={isActive ? handleToggleTtsAvatar : undefined} + videoEnabled={videoEnabled} + onToggleVideo={isActive ? handleToggleVideo : undefined} /> ) @@ -6450,9 +6502,15 @@ function App() { onTtsModeChange={handleTtsModeChange} ttsAvatarEnabled={ttsAvatarEnabled} onToggleTtsAvatar={handleToggleTtsAvatar} + videoEnabled={videoEnabled} + onToggleVideo={handleToggleVideo} onComposioConnected={handleComposioConnected} /> )} + {/* Webcam PiP preview while video chat mode is on */} + {videoEnabled && ( + + )} {/* Talking head hovers over the active view while avatar voice mode is on (hidden during the tour, which shows its own mascot) */} {ttsAvatarEnabled && !tourActive && ( diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index ebc03254..25d1be5e 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -25,6 +25,7 @@ import { ShieldCheck, Square, Terminal, + Video, X, } from 'lucide-react' @@ -238,6 +239,9 @@ interface ChatInputInnerProps { onTtsModeChange?: (mode: 'summary' | 'full') => void ttsAvatarEnabled?: boolean onToggleTtsAvatar?: () => void + /** Video chat mode: live webcam frames are attached to each message. */ + videoEnabled?: boolean + onToggleVideo?: () => void /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ onSelectedModelChange?: (model: SelectedModel | null) => void /** Work directory for this chat (per-chat). Null when none is set. */ @@ -278,6 +282,8 @@ function ChatInputInner({ onTtsModeChange, ttsAvatarEnabled, onToggleTtsAvatar, + videoEnabled, + onToggleVideo, onSelectedModelChange, workDir = null, onWorkDirChange, @@ -1350,6 +1356,33 @@ function ChatInputInner({ )} + {onToggleVideo && ( + + + + + + {videoEnabled ? 'Video chat on — frames are shared with the assistant' : 'Video chat off'} + + + )} {voiceAvailable && onStartRecording && ( +