import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useAuthStore } from '@/stores/auth-store'; import { useCreateParticle, useCreateStreamParticle, } from '@/hooks/use-create-particle'; import { QuotaExceededError } from '@/lib/errors'; import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage, } from '@/hooks/use-network-usage'; import { useRecorder } from '@/features/compose/use-recorder'; import { useScreenRecorder } from '@/features/compose/use-screen-recorder'; import { particlePath, parseParticlePath } from '@/lib/particle-path'; import type { ParticlePath } from '@/lib/particle-path'; import { RecordingOverlay } from '@/features/compose/recording-overlay'; import { ScreenSourcePicker } from '@/components/screen-source-picker'; import { KeyHint } from '@/components/key-hint'; import { TextComposeStep } from '@/features/compose/text-compose-step'; import { TaskComposeStep } from '@/features/compose/task-compose-step'; import { ConfigureContainerStep } from '@/features/compose/configure-container-step'; import type { TaskProperties } from '@/api/types'; import { apiClient } from '@/api/client'; import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaDevicesStore } from '@/stores/media-devices-store'; import { useMediaDevices } from '@/hooks/use-media-devices'; import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id'; import { useFileInput } from '@/hooks/use-file-input'; import { createImageThumbnail } from '@/lib/image-thumbnail'; import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants'; import type { PendingAttachment } from '@/features/compose/attachment-strip'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { platform } from '@/lib/platform'; import { requireDesktop } from '@/lib/platform/desktop-only'; export type ComposeStep = | 'idle' | 'picking' | 'recording' | 'reviewing' | 'typing' | 'task' | 'configuring' | 'submitting'; type RecordingSource = 'media' | 'screen'; type PendingArtifact = { type: 'task'; properties: TaskProperties }; interface ComposeOverlayProps { networkId: string; // Optional target path for reply mode. If not provided, compose creates a new stream. targetPath?: ParticlePath; onActiveChange?: (active: boolean) => void; onStepChange?: (step: ComposeStep) => void; onParticleCreated?: (particleId: string) => void; } const HOLD_THRESHOLD_MS = 250; /** * Self-contained compose overlay. Each consumer renders its own instance * with props that determine the mode (new stream vs. reply). */ export function ComposeOverlay({ networkId, targetPath, onActiveChange, onStepChange, onParticleCreated, }: ComposeOverlayProps) { const [step, setStep] = useState('idle'); const [error, setError] = useState(null); const [textContent, setTextContent] = useState(''); const [mediaStream, setMediaStream] = useState(null); const [reviewBlob, setReviewBlob] = useState(null); const [reviewDurationMs, setReviewDurationMs] = useState(0); const [reviewMimeType, setReviewMimeType] = useState(null); const [attachments, setAttachments] = useState([]); const [recordingSource, setRecordingSource] = useState('media'); const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const savedMic = useMediaDevicesStore((s) => s.mic); const savedCamera = useMediaDevicesStore((s) => s.camera); const { audioInputs, videoInputs } = useMediaDevices(); const micDeviceId = resolveEffectiveDeviceId(savedMic, audioInputs); const cameraDeviceId = resolveEffectiveDeviceId(savedCamera, videoInputs); const userId = useAuthStore((s) => s.user?.id); const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); const { data: usage } = useNetworkUsage(networkId); const invalidateUsage = useInvalidateNetworkUsage(); const quotaExhausted = isUsageExhausted(usage); // Latest props/state for synchronous reads in keyboard handlers. const stepRef = useRef(step); const recordStartRef = useRef(0); const quotaExhaustedRef = useRef(quotaExhausted); const recordingSourceRef = useRef(recordingSource); const pendingArtifactRef = useRef(null); useEffect(() => { quotaExhaustedRef.current = quotaExhausted; recordingSourceRef.current = recordingSource; }, [quotaExhausted, recordingSource]); const setStepSync = useCallback((next: ComposeStep) => { stepRef.current = next; setStep(next); }, []); useSuspendPlayback(step !== 'idle', 'compose'); // Notify parent when active state changes useEffect(() => { onActiveChange?.(step !== 'idle'); onStepChange?.(step); // Refresh quota when the overlay activates — user is about to send, so // we want the most accurate count before the client-side gate kicks in. if (step !== 'idle') { void invalidateUsage(networkId); } }, [step, onActiveChange, onStepChange, invalidateUsage, networkId]); const revokeAttachmentThumbnails = useCallback( (items: PendingAttachment[]) => { for (const a of items) { if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl); } }, [], ); const cancel = useCallback(() => { setStepSync('idle'); setError(null); setMediaStream(null); setReviewBlob(null); setReviewDurationMs(0); setReviewMimeType(null); setRecordingSource('media'); pendingArtifactRef.current = null; setAttachments((prev) => { revokeAttachmentThumbnails(prev); return []; }); }, [setStepSync, revokeAttachmentThumbnails]); const addAttachments = useCallback( async (files: File[]) => { const available = MAX_ATTACHMENTS - attachments.length; if (available <= 0) { toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`); return; } const accepted = files.slice(0, available); if (accepted.length < files.length) { toast.error( `Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`, ); } const newAttachments: PendingAttachment[] = []; for (const file of accepted) { if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { toast.error(`${file.name} is too large (max 25 MB)`); continue; } const thumbnailUrl = await createImageThumbnail(file); newAttachments.push({ id: crypto.randomUUID(), file, thumbnailUrl, status: 'pending', }); } if (newAttachments.length > 0) { setAttachments((prev) => [...prev, ...newAttachments]); } }, [attachments.length], ); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => { const removed = prev.find((a) => a.id === id); if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl); return prev.filter((a) => a.id !== id); }); }, []); const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ onFilesSelected: addAttachments, enabled: step === 'typing' || step === 'reviewing', }); const { startRecording, stopRecording, cancelRecording } = useRecorder({ mode: recordingMode, micDeviceId, cameraDeviceId, onStreamReady: (stream) => setMediaStream(stream), onStreamCleanup: () => setMediaStream(null), onFinish: (blob, durationMs, mimeType) => { setStepSync('reviewing'); setReviewBlob(blob); setReviewDurationMs(durationMs); setReviewMimeType(mimeType); }, onError: (message) => setError(message), }); const { startRecording: startScreenRecording, stopRecording: stopScreenRecording, cancelRecording: cancelScreenRecording, } = useScreenRecorder({ micDeviceId, onFinish: (blob, durationMs, mimeType) => { setStepSync('reviewing'); setReviewBlob(blob); setReviewDurationMs(durationMs); setReviewMimeType(mimeType); }, onError: (message) => { setError(message); cancel(); }, }); // --- Submission --- const uploadMedia = useCallback( async (blob: Blob, mimeType: string) => { const ext = 'webm'; const fileName = `recording-${Date.now()}.${ext}`; const { object_id, upload_url, upload_headers } = await apiClient.prepareUpload({ network_id: networkId, name: fileName, content_type: mimeType, content_length: blob.size, }); await fetch(upload_url, { method: 'PUT', headers: upload_headers, body: blob, }); await apiClient.confirmUpload(object_id); return { object_id, size_bytes: blob.size }; }, [networkId], ); const uploadFile = useCallback( async (file: File) => { const { object_id, upload_url, upload_headers } = await apiClient.prepareUpload({ network_id: networkId, name: file.name, content_type: file.type || 'application/octet-stream', content_length: file.size, }); await fetch(upload_url, { method: 'PUT', headers: upload_headers, body: file, }); await apiClient.confirmUpload(object_id); return { object_id, size_bytes: file.size }; }, [networkId], ); const uploadAttachments = useCallback( async (parentPath: ParticlePath, parentId: string) => { if (attachments.length === 0 || !userId) return; const { networkId: netId, segments } = parseParticlePath(parentPath); const childrenPath = particlePath(netId, [...segments, parentId]); const results = await Promise.allSettled( attachments.map(async (attachment) => { setAttachments((prev) => prev.map((a) => a.id === attachment.id ? { ...a, status: 'uploading' as const } : a, ), ); const { object_id } = await uploadFile(attachment.file); await createParticle.mutateAsync({ path: childrenPath, type: 'file', properties: { object_id, filename: attachment.file.name, mime_type: attachment.file.type || 'application/octet-stream', size_bytes: attachment.file.size, }, createdByHumanId: userId, }); }), ); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length > 0) { toast.error( `${failed.length} attachment${failed.length > 1 ? 's' : ''} failed to upload`, ); } }, [attachments, userId, uploadFile, createParticle], ); const createChildParticle = useCallback( async (path: ParticlePath) => { if (!userId) return; let particleId: undefined | string; const pendingArtifact = pendingArtifactRef.current; if (pendingArtifact) { particleId = await createParticle.mutateAsync({ path, type: pendingArtifact.type, properties: pendingArtifact.properties, createdByHumanId: userId, }); } else if (textContent.trim()) { particleId = await createParticle.mutateAsync({ path, type: 'text', properties: { content: textContent }, createdByHumanId: userId, }); } else if (reviewBlob && reviewMimeType) { const { object_id, size_bytes } = await uploadMedia( reviewBlob, reviewMimeType, ); const isAudioOnly = reviewMimeType.startsWith('audio/'); particleId = await createParticle.mutateAsync({ path, type: 'media', properties: { object_id, mime_type: reviewMimeType, duration_ms: reviewDurationMs, size_bytes, ...(!isAudioOnly && { source: recordingSource === 'screen' ? ('screen' as const) : ('camera' as const), }), }, createdByHumanId: userId, }); } if (particleId) { await uploadAttachments(path, particleId); onParticleCreated?.(particleId); } }, [ userId, textContent, reviewBlob, reviewMimeType, reviewDurationMs, recordingSource, createParticle, uploadMedia, uploadAttachments, onParticleCreated, ], ); const handleQuotaError = useCallback( (err: unknown): boolean => { if (err instanceof QuotaExceededError) { toast.error( 'Daily message limit reached. Upgrade to Pro to keep sending.', ); cancel(); return true; } return false; }, [cancel], ); // Reply mode: create particle directly under targetPath. const onSubmitReply = useCallback(async () => { if (!targetPath || !userId || stepRef.current === 'submitting') return; setStepSync('submitting'); try { await createChildParticle(targetPath); cancel(); setTextContent(''); } catch (err) { if (!handleQuotaError(err)) throw err; } }, [ targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError, ]); // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { if (!userId || stepRef.current === 'submitting') return; setStepSync('submitting'); try { const streamId = await createStream.mutateAsync({ networkId, properties: { name: streamName, }, createdByHumanId: userId, visibleTo, }); const streamChildrenPath = particlePath(networkId, [streamId]); await createChildParticle(streamChildrenPath); cancel(); setTextContent(''); } catch (err) { if (!handleQuotaError(err)) throw err; } }, [ networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync, ], ); // --- Compose intent handlers --- // Single source of truth for the step transitions triggered by the user. // Both the keyboard handler and the intent store dispatch into these so // guards (quota) and screen-vs-media branching live in one place. const guardIdle = useCallback((): boolean => { if (stepRef.current !== 'idle') return false; if (quotaExhaustedRef.current) { toast.info( 'Daily message limit reached. Upgrade to Pro to keep sending.', ); return false; } return true; }, []); const handleRecordIntent = useCallback(() => { if (!guardIdle()) return; recordStartRef.current = Date.now(); setRecordingSource('media'); setStepSync('recording'); startRecording(); }, [guardIdle, setStepSync, startRecording]); const handleTextIntent = useCallback(() => { if (!guardIdle()) return; setStepSync('typing'); }, [guardIdle, setStepSync]); const handleTaskIntent = useCallback(() => { if (!guardIdle()) return; setStepSync('task'); }, [guardIdle, setStepSync]); // Artifact submit: capture the artifact, then reuse the standard flow — // reply mode creates it under targetPath, root mode configures a stream // that will hold it as its first child. const handleArtifactSubmit = useCallback( (artifact: PendingArtifact) => { pendingArtifactRef.current = artifact; if (targetPath) { void onSubmitReply(); } else { setStepSync('configuring'); } }, [targetPath, onSubmitReply, setStepSync], ); const handleStopIntent = useCallback(() => { if (stepRef.current !== 'recording') return; if (recordingSourceRef.current === 'screen') { stopScreenRecording(); } else { stopRecording(); } }, [stopRecording, stopScreenRecording]); const handleCancelIntent = useCallback(() => { const s = stepRef.current; if (s === 'recording' || s === 'reviewing') { if (recordingSourceRef.current === 'screen') { cancelScreenRecording(); } else { cancelRecording(); } cancel(); } else if (s === 'typing' || s === 'configuring' || s === 'picking') { cancel(); } }, [cancel, cancelRecording, cancelScreenRecording]); const handleSendIntent = useCallback(() => { if (stepRef.current !== 'reviewing') return; if (targetPath) { onSubmitReply(); } else { setStepSync('configuring'); } }, [targetPath, onSubmitReply, setStepSync]); // --- Intent store subscription --- // External callers (clickable hints) dispatch via the store; this overlay // executes the matching handler and clears the intent. Keyboard handlers // call the same handlers directly without a store round-trip. const clearIntent = useComposeIntentStore((s) => s.clear); // Consume fire-and-forget intents from the external store. Reacting in the // store subscription (not an effect body) keeps these state-updating handlers // off the render path and avoids an extra dispatch→render bounce. useEffect(() => { return useComposeIntentStore.subscribe((state, prev) => { const intent = state.intent; if (!intent || intent === prev.intent) return; switch (intent.kind) { case 'record': handleRecordIntent(); break; case 'text': handleTextIntent(); break; case 'task': handleTaskIntent(); break; case 'stop': handleStopIntent(); break; case 'cancel': handleCancelIntent(); break; case 'send': handleSendIntent(); break; } clearIntent(); }); }, [ handleRecordIntent, handleTextIntent, handleTaskIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent, ]); // --- Keyboard handling --- useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const currentStep = stepRef.current; if ( currentStep === 'typing' || currentStep === 'task' || currentStep === 'configuring' || currentStep === 'picking' ) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } return; } const target = e.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable ) { return; } switch (currentStep) { case 'idle': { if (e.key === '`' && !e.repeat) { e.preventDefault(); handleRecordIntent(); } else if (e.key === 's' || e.key === 'S') { e.preventDefault(); if (!guardIdle()) break; if (!requireDesktop('Screen recording')) break; setRecordingSource('screen'); setStepSync('picking'); } else if (e.key === 't' || e.key === 'T') { e.preventDefault(); handleTextIntent(); } else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); handleTaskIntent(); } break; } case 'recording': { if (e.key === '`' && !e.repeat) { // Second tap stops media recording (toggle mode) e.preventDefault(); handleStopIntent(); } else if ( (e.key === 's' || e.key === 'S') && recordingSourceRef.current === 'screen' ) { // S stops screen recording when main window is focused e.preventDefault(); handleStopIntent(); } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { e.preventDefault(); handleCancelIntent(); } break; } case 'reviewing': { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { e.preventDefault(); handleCancelIntent(); } else if (e.key === 'Enter') { e.preventDefault(); handleSendIntent(); } break; } } }; const handleKeyUp = (e: KeyboardEvent) => { if ( stepRef.current === 'recording' && e.key === '`' && recordingSourceRef.current === 'media' ) { e.preventDefault(); // Only stop on release if held long enough (hold-to-record mode). // Quick taps are handled by the second keydown (toggle mode). if ( recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS ) { handleStopIntent(); recordStartRef.current = 0; } } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [ cancel, setStepSync, guardIdle, handleRecordIntent, handleTextIntent, handleTaskIntent, handleStopIntent, handleCancelIntent, handleSendIntent, ]); // --- Screen source selection handler --- const handleScreenSourceSelected = useCallback( (sourceId: string) => { setStepSync('recording'); startScreenRecording(sourceId); }, [setStepSync, startScreenRecording], ); // --- Render --- if (step === 'idle') return null; const handleTextAdvance = targetPath ? onSubmitReply : () => setStepSync('configuring'); return ( <> {step === 'picking' && ( )} {(step === 'recording' || step === 'reviewing') && recordingSource === 'media' && ( )} {step === 'recording' && recordingSource === 'screen' && (
Recording screen…
stop cancel
)} {step === 'reviewing' && recordingSource === 'screen' && reviewBlob && ( )} {step === 'typing' && ( )} {step === 'task' && ( handleArtifactSubmit({ type: 'task', properties }) } /> )} {!targetPath && step === 'configuring' && ( )} {step === 'submitting' && (
Sending...
)} ); }