import { captureClientException } from "@agent-native/core/client/analytics"; import { agentNativePath, appBasePath, } from "@agent-native/core/client/api-path"; import { callAction } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useLiveTranscription } from "@agent-native/core/client/transcription/use-live-transcription"; import type { BrowserDiagnosticsData } from "@shared/browser-diagnostics"; import { isStoredButUnservableFinalizeError, waitForAcceptedRecordingAfterFinalizeError, } from "@shared/finalize-recovery"; import { chunkUploadParallelism, chunkUploadUrl, pickMimeType, type UploadMode, } from "@shared/recording-core"; import { IconAlertTriangle, IconArrowLeft, IconCamera, IconDeviceDesktop, IconDownload, IconExternalLink, IconMicrophone, IconRefresh, IconVideo, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { Link, useLocation, useNavigate } from "react-router"; import { Skeleton } from "@/components/ui/skeleton"; import { useDesktopPromo } from "@/hooks/use-desktop-promo"; import { fetchVideoStorageStatus, useVideoStorageStatus, VIDEO_STORAGE_STATUS_KEY, type VideoStorageStatus, } from "@/hooks/use-video-storage-status"; import enMessages from "@/i18n/en-US"; import { createBrowserDiagnosticsCapture, type BrowserDiagnosticsCapture, } from "@/lib/browser-diagnostics-capture"; import { getCaptureHostApp, macPermissionGuidanceFor, } from "@/lib/capture-permissions"; import { COMPRESS_THRESHOLD_BYTES, COMPRESSION_ENABLED, MAX_UPLOAD_BYTES, compressBlobIfTooLarge, formatMb, } from "@/lib/compress"; import { createCountdownAudioCue, type CountdownAudioCue, } from "@/lib/countdown-audio-cue"; import { loadRecorderPreferences, saveRecorderPreferences, } from "@/lib/recorder-preferences"; import { copyRecordingShareLink } from "@/lib/recording-link"; import { buildCaptureTitle, defaultRecordingTitle, inferWindowTitleFromDisplayStream, } from "@/lib/recording-title"; import { decideRecordingVisibilityAction, isMobileRecorderRuntime, } from "@/lib/recording-visibility"; import { cn } from "@/lib/utils"; // Client-side app-state writer (the server module pulls in Node's `events` // and cannot be bundled for the browser). async function writeAppState(key: string, value: unknown): Promise { await fetch( agentNativePath( `/_agent-native/application-state/${encodeURIComponent(key)}`, ), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(value), }, ); } import { bugReportTitle, parseBugReportContext, type BugReportContext, } from "@shared/bug-report"; import { toast } from "sonner"; import { CaptureInstallButton } from "@/components/capture-install-options"; import { CameraBubble } from "@/components/recorder/camera-bubble"; import type { CameraBubbleSize } from "@/components/recorder/camera-bubble"; import { ConfettiCanvas, type ConfettiHandle, } from "@/components/recorder/confetti-canvas"; import { CountdownOverlay } from "@/components/recorder/countdown-overlay"; import { PreRecordPanel } from "@/components/recorder/pre-record-panel"; import { RecorderEngine, canUseTimeslicedRecorderChunks, NO_MIC_DEVICE_ID, type DisplaySurface, type RecorderFinalizeResult, type RecordingMode, } from "@/components/recorder/recorder-engine"; import { RecordingToolbar } from "@/components/recorder/recording-toolbar"; import { StorageSetupCard } from "@/components/recorder/storage-setup-card"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; export function meta() { return [{ title: enMessages.recordRoute.pageTitle }]; } export function headers() { return { "Permissions-Policy": "camera=(self), microphone=(self), display-capture=(self), geolocation=(), screen-wake-lock=()", }; } type UiState = | "idle" | "pickingSources" | "countdown" | "recording" | "compressing" | "uploading" | "complete" | "error"; type ClipsExtensionCapture = { extensionId: string; sessionId: string; sourceUrl: string | null; developerLogsEnabled: boolean; }; type ClipsExtensionDiagnosticsResponse = { ok?: boolean; diagnostics?: BrowserDiagnosticsData; error?: string; }; const MAC_SCREEN_RECORDING_PREF_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; const MAC_CAMERA_PREF_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"; const MAC_MICROPHONE_PREF_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"; type BrowserDocumentPolicy = { allowsFeature?: (feature: string) => boolean; }; function isMacPlatform(): boolean { return /^darwin|mac/i.test( typeof navigator !== "undefined" ? navigator.platform : "", ); } function isEmbeddedWindow(): boolean { if (typeof window === "undefined") return false; try { return window.self !== window.top; } catch { return true; } } function openUrlFromUserGesture(url: string): void { const opened = window.open(url, "_blank", "noopener,noreferrer"); if (!opened) { window.location.href = url; } } function bugReportDonePath(recordingId: string, context: BugReportContext) { const params = new URLSearchParams({ recordingId }); if (context.returnUrl) params.set("returnUrl", context.returnUrl); return `/bug-report/done?${params.toString()}`; } function sendClipsExtensionMessage( extensionId: string, message: Record, ): Promise { const runtime = (globalThis as { chrome?: any }).chrome?.runtime; if (!runtime?.sendMessage) return Promise.resolve(null); return new Promise((resolve) => { try { runtime.sendMessage(extensionId, message, (response: T | undefined) => { if (runtime.lastError) { console.warn("[recorder] Clips extension message failed:", { message: runtime.lastError.message, type: message.type, }); resolve(null); return; } resolve(response ?? null); }); } catch (err) { console.warn("[recorder] Clips extension message failed:", err); resolve(null); } }); } function capturePolicy(): BrowserDocumentPolicy | null { if (typeof document === "undefined") return null; const doc = document as Document & { permissionsPolicy?: BrowserDocumentPolicy; featurePolicy?: BrowserDocumentPolicy; }; return doc.permissionsPolicy ?? doc.featurePolicy ?? null; } function isCaptureFeatureBlockedByPolicy(feature: string): boolean { const policy = capturePolicy(); if (!policy?.allowsFeature) return false; try { return !policy.allowsFeature(feature); } catch { return false; } } function getPolicyBlockedCaptureLabel(opts: { mode: RecordingMode; micDeviceId?: string | null; }): "screen" | "camera" | "microphone" | null { if ( (opts.mode === "screen" || opts.mode === "screen+camera") && isCaptureFeatureBlockedByPolicy("display-capture") ) { return "screen"; } if ( (opts.mode === "camera" || opts.mode === "screen+camera") && isCaptureFeatureBlockedByPolicy("camera") ) { return "camera"; } if ( wantsMicrophone(opts.micDeviceId) && isCaptureFeatureBlockedByPolicy("microphone") ) { return "microphone"; } return null; } function directRecorderUrl(opts?: { mode: RecordingMode; displaySurface: DisplaySurface; }): string { if (typeof window === "undefined") return "/record"; const url = new URL(window.location.href); if (opts) { url.searchParams.set("mode", opts.mode); url.searchParams.set("surface", opts.displaySurface); } return url.toString(); } function isPermissionError(message: string): boolean { // Device-busy errors ("That camera is busy in another app", "Microphone is // currently in use") mention the device name but are not permission failures // — sending the user to enable a permission they already have wastes their // time. Require an explicit permission/denied/blocked keyword to qualify. const isDeviceBusy = /\b(busy|in use|already in use|in another (app|application|tab)|currently used|conflicting)\b/i.test( message, ); const hasPermissionKeyword = /\b(permission|blocked|denied|not allowed|privacy|allow|disable[d]?|enable)\b/i.test( message, ); if (isDeviceBusy && !hasPermissionKeyword) return false; return /screen|camera|microphone|mic|permission|blocked|denied|not allowed|privacy/i.test( message, ); } function isPolicyPermissionError(message: string): boolean { return /permissions-policy|app frame|embedding frame|frame that allows/i.test( message, ); } function isScreenPermissionError(message: string): boolean { return ( isPermissionError(message) && /screen|display|share|system audio|screen recording|Screen & System Audio Recording/i.test( message, ) ); } function isCameraPermissionError(message: string): boolean { return isPermissionError(message) && /camera/i.test(message); } function isMicrophonePermissionError(message: string): boolean { return isPermissionError(message) && /microphone|mic/i.test(message); } function wantsMicrophone(micDeviceId?: string | null): boolean { return micDeviceId !== NO_MIC_DEVICE_ID; } function getModePermissionLabels( mode?: RecordingMode, micDeviceId?: string | null, ): Array<"screen" | "camera" | "microphone"> { const labels: Array<"screen" | "camera" | "microphone"> = []; if (mode === "screen" || mode === "screen+camera") labels.push("screen"); if (mode === "camera" || mode === "screen+camera") labels.push("camera"); if (mode && wantsMicrophone(micDeviceId)) labels.push("microphone"); return labels; } function getPreparingSourcesCopy( mode: RecordingMode, micDeviceId?: string | null, ): string { const labels = getModePermissionLabels(mode, micDeviceId); if (labels.length === 0) return "Choose a source before recording starts."; const readable = labels.map((label) => label === "microphone" ? "microphone" : label, ); const last = readable.pop(); return `Allow ${readable.length ? `${readable.join(", ")} and ${last}` : last} access before recording starts.`; } function permissionGuidance( message: string, opts?: { mode?: RecordingMode; micDeviceId?: string | null }, ): string | null { if (isUploadFailureError(message)) return null; if (!isPermissionError(message)) return null; if (isPolicyPermissionError(message)) { if (opts?.mode === "screen") { return "Browser site permissions are not the blocker here. Open Clips directly in a browser tab, or use an app frame that delegates screen capture."; } return "Browser site permissions are not the blocker here. Open Clips directly in a browser tab, or use an app frame that delegates the selected capture sources."; } if (isScreenPermissionError(message)) { // The desktop shell hosts the recorder in its own webview, so "open it in a // real tab" is not advice a desktop user can act on. if (isEmbeddedWindow() && getCaptureHostApp().kind !== "desktop") { return "The web client is running Clips inside a frame. Open the recorder in its own tab, then start recording; Chrome can block screen sharing in embedded pages even when macOS access is enabled."; } if (isMacPlatform()) { return `Camera and Microphone can be allowed while macOS still blocks screen capture. ${macPermissionGuidanceFor("screen")}`; } return "Choose a source in the browser screen picker. If it still fails, check this site's browser permissions and reload Clips."; } if (isCameraPermissionError(message)) { if (isMacPlatform()) { return `Allow Camera for this site first. ${macPermissionGuidanceFor("camera")}`; } return "Open this site's browser settings, allow Camera, then reload Clips."; } if (isMicrophonePermissionError(message)) { if (isMacPlatform()) { return `Allow Microphone for this site first. ${macPermissionGuidanceFor("microphone")}`; } return "Open this site's browser settings, allow Microphone, then reload Clips."; } if (isMacPlatform()) { const host = getCaptureHostApp(); const labels = getModePermissionLabels(opts?.mode, opts?.micDeviceId); if (labels.length > 0) { const readable = labels .map((label) => label === "screen" ? "Screen & System Audio Recording" : label === "camera" ? "Camera" : "Microphone", ) .join(", "); return `Check this site's permissions first. If it still fails, turn on ${host.name} under ${readable} in macOS System Settings > Privacy & Security, then quit and reopen it. Clips is never listed there — macOS grants access to ${host.name}.`; } return `Check this site's permissions first. If it still fails, turn on ${host.name} in macOS System Settings > Privacy & Security, then quit and reopen it.`; } return "Open this site's browser settings and allow the selected capture sources, then reload this page."; } function permissionSettingsUrl( message: string, mode?: RecordingMode, ): string | null { if (isUploadFailureError(message)) return null; if (!isMacPlatform() || isPolicyPermissionError(message)) return null; if (isScreenPermissionError(message)) return MAC_SCREEN_RECORDING_PREF_URL; if (isCameraPermissionError(message)) return MAC_CAMERA_PREF_URL; if (isMicrophonePermissionError(message)) return MAC_MICROPHONE_PREF_URL; if (mode === "screen") return MAC_SCREEN_RECORDING_PREF_URL; return MAC_SCREEN_RECORDING_PREF_URL; } function isDismissedCapturePicker(err: unknown, message: string): boolean { const name = err instanceof Error ? err.name : ""; return ( name === "AbortError" || /screen sharing was cancelled|cancelled|canceled|dismissed/i.test(message) ); } function getRecordingModeParam(value: string | null): RecordingMode | null { if (value === "screen" || value === "camera") return value; if ( value === "screen+camera" || value === "screen camera" || value === "screen-camera" ) { return "screen+camera"; } return null; } function makeAbortError(message: string): Error { const error = new Error(message); error.name = "AbortError"; return error; } function getDisplaySurfaceParam(value: string | null): DisplaySurface | null { if (value === "monitor" || value === "window" || value === "browser") { return value; } if (value === "screen") return "monitor"; return null; } function getRecordingErrorTitle( error: string, t: ReturnType, ): string { if (isUploadSizeError(error)) return t("recordRoute.videoTooLarge"); if (isUploadFailureError(error)) return t("recordRoute.uploadFailed"); if (isScreenPermissionError(error)) return "Screen recording needs access"; if (isCameraPermissionError(error)) return "Camera needs access"; if (isMicrophonePermissionError(error)) return "Microphone needs access"; return t("recordRoute.couldNotStartRecording"); } function isUploadSizeError(error: string): boolean { return /too large to upload|too large for clips|limit is \d|file is too large|file size/i.test( error, ); } function uploadTooLargeMessage(size: number, detail?: string): string { return `Video is too large to upload (${ detail ?? formatMb(size) }, limit is ${formatMb( MAX_UPLOAD_BYTES, )}) after automatic compression. Trim or export a shorter copy and upload again.`; } /** Pre-upload size rejection for a picked file — no compression has been * attempted yet, so the message must not imply it has. */ function fileTooLargeMessage(size: number): string { return `This file is too large to upload (${formatMb( size, )}, limit is ${formatMb( MAX_UPLOAD_BYTES, )}). Trim it or export a shorter copy and try again.`; } function isUploadFailureError(error: string): boolean { return ( isUploadSizeError(error) || /upload failed|chunk|reset-chunks|re-upload/i.test(error) ); } function friendlyRecordingErrorMessage(error: string): string { if (isUploadSizeError(error)) { return `This video is too large for Clips. Trim or export a shorter copy under ${formatMb( MAX_UPLOAD_BYTES, )} and upload again.`; } if (isUploadFailureError(error)) { return "The video could not finish uploading. Retry the upload before starting over."; } if (isPolicyPermissionError(error)) { return "This recorder is embedded somewhere that blocks capture permissions."; } if (isScreenPermissionError(error)) { if (isMacPlatform()) { return `Clips could not start screen capture. macOS is blocking screen recording for ${getCaptureHostApp().name}.`; } return "Clips could not start screen capture. Allow screen sharing for this site, then try again."; } if (isCameraPermissionError(error)) { return "Clips could not start the camera. Allow camera access, then try again."; } if (isMicrophonePermissionError(error)) { return "Clips could not start the microphone. Allow microphone access, then try again."; } if (error.length > 220) { return "Something blocked the recorder before it could start."; } return error; } function userFacingActionErrorMessage(error: string): string { return error.replace(/^Action [a-z0-9-]+ failed:\s*/i, "").trim() || error; } interface PendingRecording { id: string; uploadChunkUrl: string; abortUrl: string; uploadMode?: UploadMode; } function PreRecordPanelSkeleton() { return (
); } function DesktopRecorderCallout() { const t = useT(); return ( ); } function RecordingErrorCard({ error, mode, micDeviceId, canRetryUpload, canDownloadRecording, onDownloadRecording, onTryAgain, }: { error: string; mode: RecordingMode; micDeviceId: string | null; canRetryUpload: boolean; canDownloadRecording: boolean; onDownloadRecording: () => void; onTryAgain: () => void; }) { const t = useT(); const uploadFailure = isUploadFailureError(error); const guidance = uploadFailure ? null : permissionGuidance(error, { mode, micDeviceId }); const permissionError = !uploadFailure && isPermissionError(error); const policyError = !uploadFailure && isPolicyPermissionError(error); const embeddedScreenError = !uploadFailure && isEmbeddedWindow() && getCaptureHostApp().kind !== "desktop" && isScreenPermissionError(error); const settings = permissionError ? getModePermissionLabels(mode, micDeviceId) : []; const directUrl = directRecorderUrl(); const friendlyMessage = friendlyRecordingErrorMessage(error); const showTechnicalDetails = friendlyMessage !== error; return (

{getRecordingErrorTitle(error, t)}

{friendlyMessage}

{showTechnicalDetails && (
{t("recordRoute.technicalDetails")}

{error}

)}
{guidance && (
{t("recordRoute.whatToCheck")}

{guidance}

)}
{/* Offer the download whenever local recording data survives, no matter why the upload failed — the user should never lose their video. They can re-import the saved file later via "Upload a video file". */} {canDownloadRecording && ( <>

Your recording is safe on this device. Download it now and upload the file later from the recorder if it still won't save.

)} {(policyError || embeddedScreenError) && ( )} {permissionError && isMacPlatform() && !policyError && settings.length > 0 && (
{settings.includes("screen") && ( )} {settings.includes("camera") && ( )} {settings.includes("microphone") && ( )}
)}
); } export default function RecordRoute() { const t = useT(); const navigate = useNavigate(); const location = useLocation(); // A clipboard write can be refused (insecure origin, unfocused document, no // transient activation). Never let the success toast imply the link was // copied when it wasn't — offer a click instead, which restores the user // gesture the browser is asking for. const showSavedToast = useCallback( (message: string, copied: boolean, recordingId: string) => { if (copied) { toast.success(message, { description: t("recordRoute.linkCopied") }); return; } toast.success(message, { action: { label: t("recordRoute.copyLinkAction"), onClick: () => { void copyRecordingShareLink(recordingId); }, }, }); }, [t], ); const [uiState, setUiState] = useState("idle"); const [error, setError] = useState(null); const [isPaused, setIsPaused] = useState(false); const visibilityAutoPausedRef = useRef(false); const [cameraStream, setCameraStream] = useState(null); const [cameraSize, setCameraSize] = useState( () => loadRecorderPreferences().cameraSize ?? "md", ); // Remember the bubble size across visits alongside the panel's selections. const handleCameraSizeChange = useCallback((size: CameraBubbleSize) => { setCameraSize(size); saveRecorderPreferences({ cameraSize: size }); }, []); const [previewStream, setPreviewStream] = useState(null); // The capture surface the user actually picked in the browser's native screen // picker (authority over the requested `displaySurface` hint). Drives whether // the live camera bubble is hidden during full-screen recording. const [resolvedDisplaySurface, setResolvedDisplaySurface] = useState(null); const [loomImporting, setLoomImporting] = useState(false); const [recordingMode, setRecordingMode] = useState("screen+camera"); // Surfaced during the post-stop compression pass so the spinner can show // "Compressing… 42%" instead of "Saving your recording…" — otherwise // multi-minute encodes on long screen recordings look frozen. const [compressionProgress, setCompressionProgress] = useState( null, ); // Fraction (0-1) of upload chunks confirmed sent so far. Chunks are fixed-size // slices of the already-recorded blob, so chunksSent / totalChunks is a // truthful proxy for bytes uploaded — not simulated. Null means the total // chunk count isn't known yet (e.g. the brief live-streaming remainder // upload), so the overlay falls back to an indeterminate spinner. const [uploadProgress, setUploadProgress] = useState(null); const queryClient = useQueryClient(); const { isDesktopApp } = useDesktopPromo(); const storageQuery = useVideoStorageStatus(); // When the user clicks "Record for this space/folder", the empty-state CTA // appends ?spaceId or ?folderId so the new recording lands there. const spaceIdFromUrl = useMemo(() => { const params = new URLSearchParams(location.search); return params.get("spaceId") || null; }, [location.search]); const folderIdFromUrl = useMemo(() => { const params = new URLSearchParams(location.search); return params.get("folderId") || null; }, [location.search]); const storageConfigured: boolean | null = storageQuery.isLoading ? null : !!storageQuery.data?.configured; const initialRecorderOptions = useMemo(() => { const params = new URLSearchParams(location.search); const mode = params.get("mode"); const surface = params.get("surface"); return { mode: getRecordingModeParam(mode), surface: getDisplaySurfaceParam(surface), }; }, [location.search]); const extensionCapture = useMemo(() => { const params = new URLSearchParams(location.search); const extensionId = params.get("clipsExtensionId")?.trim(); const sessionId = params.get("clipsCaptureSessionId")?.trim(); if (!extensionId || !sessionId) return null; const developerLogs = params.get("developerLogs"); return { extensionId, sessionId, sourceUrl: params.get("sourceUrl")?.trim() || null, developerLogsEnabled: developerLogs !== "0", }; }, [location.search]); const bugReportContext = useMemo( () => parseBugReportContext(new URLSearchParams(location.search)), [location.search], ); const markStorageConfigured = useCallback( (status?: VideoStorageStatus) => { queryClient.setQueryData( VIDEO_STORAGE_STATUS_KEY, (prev) => status ?? { configured: true, activeProvider: prev?.activeProvider ?? null, builderConfigured: prev?.builderConfigured ?? false, }, ); }, [queryClient], ); const liveTranscription = useLiveTranscription(); const stopLiveTranscription = liveTranscription.stop; const saveBugReportContext = useCallback( async (recordingId: string) => { if (!bugReportContext) return; try { await callAction( "save-bug-report-context" as any, { recordingId, projectId: bugReportContext.projectId, title: bugReportContext.title, description: bugReportContext.description, severity: bugReportContext.severity, sourceUrl: bugReportContext.sourceUrl, pageTitle: bugReportContext.pageTitle, appVersion: bugReportContext.appVersion, environment: bugReportContext.environment, reporterEmail: bugReportContext.reporterEmail, reporterName: bugReportContext.reporterName, reporterId: bugReportContext.reporterId, metadata: bugReportContext.metadata ?? undefined, } as any, ); } catch (err) { console.warn("[recorder] bug report context save failed:", err); } }, [bugReportContext], ); const bugReportContextRef = useRef(null); const saveBugReportContextRef = useRef(saveBugReportContext); useEffect(() => { bugReportContextRef.current = bugReportContext; saveBugReportContextRef.current = saveBugReportContext; }, [bugReportContext, saveBugReportContext]); const engineRef = useRef(null); const pendingRef = useRef(null); const countdownAudioCueRef = useRef(null); const confettiRef = useRef(null); // Stable ref to doStop so engine callbacks created during startFlow always // call the latest version (avoids stale-closure problems with useCallback deps). const doStopRef = useRef<() => Promise>(async () => {}); const pendingStartOptsRef = useRef<{ mode: RecordingMode; displaySurface: DisplaySurface; micDeviceId: string | null; micDeviceLabel?: string | null; cameraDeviceId: string | null; } | null>(null); const previewVideoRef = useRef(null); const fileUploadAbortRef = useRef(null); const browserDiagnosticsRef = useRef(null); // Bumped by doCancel() to invalidate any in-flight startFlow(). const startSessionRef = useRef(0); // Elapsed-time display now ticks inside RecordingToolbar itself (via // `active` + `getElapsedMs`) so the ~4x/sec poll doesn't re-render this // whole route — see the `active`/`getElapsedMs` props passed below. // ------------------------------------------------------------------------- // Wire preview stream into its video element. // ------------------------------------------------------------------------- useEffect(() => { if (!previewVideoRef.current) return; previewVideoRef.current.srcObject = previewStream; if (previewStream) { previewVideoRef.current.play().catch(() => {}); } }, [previewStream]); const showRecordingErrorToast = useCallback( (message: string) => { const pendingOpts = pendingStartOptsRef.current; const uploadFailure = isUploadFailureError(message); const guidance = uploadFailure ? null : permissionGuidance(message, pendingOpts ?? undefined); const settingsUrl = uploadFailure ? null : permissionSettingsUrl(message, pendingOpts?.mode); const friendlyMessage = friendlyRecordingErrorMessage(message); toast.error( uploadFailure ? t("recordRoute.uploadFailed") : t("recordRoute.couldNotStartRecording"), { description: guidance ?? friendlyMessage, duration: guidance ? 20_000 : 10_000, action: settingsUrl ? { label: t("recordRoute.openSettings"), onClick: () => { openUrlFromUserGesture(settingsUrl); }, } : undefined, }, ); }, [t], ); // ------------------------------------------------------------------------- // Acquire media, create recording row, start countdown. // ------------------------------------------------------------------------- const startFlow = useCallback( async (opts: { mode: RecordingMode; displaySurface: DisplaySurface; micDeviceId: string | null; micDeviceLabel?: string | null; cameraDeviceId: string | null; }) => { const blockedFeature = isEmbeddedWindow() ? getPolicyBlockedCaptureLabel({ mode: opts.mode, micDeviceId: opts.micDeviceId, }) : null; if (blockedFeature) { openUrlFromUserGesture(directRecorderUrl(opts)); toast.info(t("recordRoute.openedRecorderInNewTab"), { description: `Chrome is blocking ${blockedFeature} access in this embedded web client.`, duration: 8000, }); return; } // Claim a session id; doCancel() bumps the ref to invalidate us. const session = startSessionRef.current + 1; startSessionRef.current = session; const isStale = () => startSessionRef.current !== session; countdownAudioCueRef.current?.cleanup(); countdownAudioCueRef.current = createCountdownAudioCue(); setError(null); setRecordingMode(opts.mode); pendingStartOptsRef.current = opts; // Clear any surface resolved by a previous capture; the engine reports the // new one once the user picks in the browser's screen dialog. setResolvedDisplaySurface(null); flushSync(() => { setUiState("pickingSources"); }); try { // Build the engine and trigger browser media prompts before any // network await. Brave drops the transient user activation after async // work, so calling getDisplayMedia after create-recording can fail // silently without showing a picker. const engine = new RecorderEngine({ recordingId: "__pending__", mode: opts.mode, displaySurface: opts.displaySurface, micDeviceId: opts.micDeviceId, micDeviceLabel: opts.micDeviceLabel, cameraDeviceId: opts.cameraDeviceId, cameraBubbleSize: cameraSize, uploadUrl: "", abortUrl: "", onError: (err) => { console.error("[recorder] error:", err); showRecordingErrorToast(err.message); setError(err.message); setUiState("error"); }, // Non-fatal device drops (camera unplugged, mic disconnected) — the // recording keeps going; just let the user know what happened. onWarning: (message) => { toast.warning(message); }, // Camera track ended mid-recording (unplugged, permission revoked, // device asleep). The recorded composite already drops the bubble; // clear the on-page preview stream too so it doesn't keep showing a // frozen last frame that no longer matches the recorded output. onCameraEnded: () => { setCameraStream(null); }, // Track the surface the user actually chose (and any mid-recording // switch) so the live camera bubble is hidden only when the full // screen — including this tab's overlay — is being captured. onResolvedDisplaySurface: (surface) => { setResolvedDisplaySurface(surface); }, onState: (state) => { // Mirror the engine's compression pass into the UI so the // "Saving your recording…" spinner becomes "Compressing…" for // the duration. Other engine states are managed by the UI's // own state machine in startFlow / doStop. if (state === "compressing") { setUiState("compressing"); } else if (state === "uploading") { // Reset compression progress when the engine moves on to // upload — applies whether or not we just came from // compressing. setCompressionProgress(null); // Reset upload progress at the start of each upload attempt so // a retry doesn't briefly show the previous attempt's percent. setUploadProgress(null); // Always sync the UI back to "uploading"; if we were already // there from doStop's pre-stop transition, this is a no-op. setUiState("uploading"); } }, onChunk: ({ index, total }) => { // `total` is only known once the full recording is sliced into // fixed-size chunks after stop(); the live per-chunk uploads // during recording report `total: null` and don't drive this bar. const fraction = total ? (index + 1) / total : null; setUploadProgress(fraction); const recordingId = pendingRef.current?.id; if (!recordingId) return; // Only expose a percentage here — this state is agent-visible, and // chunk/byte counts are an internal transport detail, not // something to surface to the user. void writeAppState(`recording-upload-${recordingId}`, { recordingId, status: "uploading", progress: fraction !== null ? Math.round(fraction * 100) : null, updatedAt: new Date().toISOString(), }).catch(() => {}); }, // When the user clicks the browser's native "Stop sharing" button, // delegate to doStop() so the UI runs its full stop flow: // transcription flush, state updates, and navigation. // Using a ref so we always call the latest version of doStop even // though startFlow itself has empty deps. onDisplayTrackEnded: () => { void doStopRef.current(); }, onCompressionProgress: ({ stage, progress }) => { // The recorder engine is responsible for transitioning into the // `compressing` state. We mirror that into the UI via the // generic onState handler below; here we just track the // numeric progress so the spinner can show a percentage. if (stage === "encoding" && typeof progress === "number") { setCompressionProgress(progress); } else if (stage === "loading-ffmpeg" || stage === "preparing") { setCompressionProgress(null); } else if (stage === "finalizing") { setCompressionProgress(1); } }, }); engineRef.current = engine; // 1. Acquire media (triggers permission prompts) while the click's // transient activation is still live. const { previewStream: ps, cameraStream: cs } = await engine.acquire(); if (isStale()) { await engine.cancel().catch(() => {}); return; } const captureTitle = buildCaptureTitle({ windowTitle: inferWindowTitleFromDisplayStream(ps), displaySurface: opts.displaySurface, mode: opts.mode, }); const wantsMic = opts.micDeviceId !== NO_MIC_DEVICE_ID; // Web Speech does not let us pin a microphone device. If the user // chose a specific mic, do not start a parallel system-default mic // session for instant transcription; the recorded audio still uses // the exact selected device and can be transcribed after upload. // // The engine reports whether the final recorded mic stream really is // the system default; corrected explicit fallbacks must not start Web // Speech because it would listen to a different device. const usingDefaultMic = engine.didMicUseSystemDefault(); if (wantsMic && usingDefaultMic && liveTranscription.supported) { liveTranscription.start(); } const status = await fetchVideoStorageStatus(); if (isStale()) { await liveTranscription.stopAndWait().catch(() => ""); await engine.cancel().catch(() => {}); return; } markStorageConfigured(status); if (!status.configured) { throw new Error( "No video storage configured. Connect storage: Builder.io (free tier storage + AI) or S3-compatible storage.", ); } // 2. Create the recording row server-side once permissions are granted. const reportContext = bugReportContextRef.current; const reportTitle = reportContext ? `Bug report: ${bugReportTitle(reportContext)}` : null; const res = await fetch( agentNativePath("/_agent-native/actions/create-recording"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: reportTitle ?? captureTitle.title, titleSource: reportTitle ? "context" : captureTitle.titleSource, sourceAppName: captureTitle.sourceAppName, sourceWindowTitle: captureTitle.sourceWindowTitle, hasCamera: opts.mode !== "screen", hasAudio: wantsMic, visibility: reportContext ? "org" : undefined, spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined, folderId: folderIdFromUrl ?? undefined, mimeType: pickMimeType() || undefined, requestStreaming: canUseTimeslicedRecorderChunks(pickMimeType()), }), }, ); if (!res.ok) { if (res.status === 401 || res.status === 403) { throw new Error("SESSION_EXPIRED"); } const body = (await res.json().catch(() => null)) as { error?: string; } | null; throw new Error( body?.error ?? `create-recording failed (${res.status})`, ); } const created = (await res.json()) as { result?: { id: string; uploadChunkUrl: string; abortUrl: string; uploadMode?: UploadMode; }; id?: string; uploadChunkUrl?: string; abortUrl?: string; uploadMode?: UploadMode; }; const info = created.result ?? (created as PendingRecording); if (!info?.id) { throw new Error("create-recording did not return an id"); } // Cancelled mid-POST: pendingRef is still null, so trash directly. if (isStale()) { await liveTranscription.stopAndWait().catch(() => ""); fetch(agentNativePath("/_agent-native/actions/trash-recording"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: info.id }), }).catch(() => {}); await engine.cancel().catch(() => {}); return; } const uploadChunkUrl = `${appBasePath()}${info.uploadChunkUrl!}`; const abortUrl = `${appBasePath()}${info.abortUrl!}`; pendingRef.current = { id: info.id, uploadChunkUrl, abortUrl, }; engine.setUploadTarget({ recordingId: info.id, uploadUrl: uploadChunkUrl, abortUrl, uploadMode: info.uploadMode, }); await saveBugReportContextRef.current(info.id); setPreviewStream(ps); setCameraStream(cs); setUiState("countdown"); } catch (err) { // doCancel() owns teardown if a cancel raced ahead — don't clobber it. if (isStale()) return; const message = err instanceof Error ? err.message : t("recordRoute.couldNotStartRecording"); const pickerDismissed = isDismissedCapturePicker(err, message); await liveTranscription.stopAndWait().catch(() => ""); // If the recording row was created before the failure, trash it so it // doesn't sit in the library forever in 'uploading' status. This // is the bug that produced "stuck UPLOADING" cards from failed // record attempts. const orphan = pendingRef.current; if (orphan?.id) { fetch(agentNativePath("/_agent-native/actions/trash-recording"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: orphan.id }), }).catch(() => {}); } // Release any tracks the engine grabbed before failing. try { await engineRef.current?.cancel(); } catch { // ignore } countdownAudioCueRef.current?.cleanup(); countdownAudioCueRef.current = null; pendingRef.current = null; engineRef.current = null; if (pickerDismissed) { setError(null); setUiState("idle"); return; } setError(message); setUiState("error"); if ( !message.includes("No video storage configured") && message !== "SESSION_EXPIRED" ) { showRecordingErrorToast(message); } } }, [liveTranscription, markStorageConfigured, showRecordingErrorToast], ); // ------------------------------------------------------------------------- // Upload a local video file as a Clip. // Reads metadata via a hidden