import { AgentPanel } from "@agent-native/core/client/agent-chat"; import { appPath, agentNativePath } from "@agent-native/core/client/api-path"; import { writeClipboardText } from "@agent-native/core/client/clipboard"; import { useActionMutation, useActionQuery, useSession, getBrowserTabId, readClientAppState, writeClientAppState, useChangeVersions, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { buildSignInReturnHref } from "@agent-native/core/client/ui"; import { BUILDER_CREDITS_UPGRADE_URL, type BuilderCreditsStatus, } from "@shared/builder-credits"; import { isStoredButUnservableFinalizeError } from "@shared/finalize-recovery"; import { isLoomEmbedBackedRecording, isLoomRecordingSource, } from "@shared/loom"; import { CLIP_SHARE_REF, DASHBOARD_REDIRECT_PARAM, DASHBOARD_REDIRECT_VALUE, REF_PARAM, } from "@shared/share-attribution"; import { IconShare3, IconArrowLeft, IconChevronDown, IconCalendar, IconScissors, IconAlertTriangle, IconHelpCircle, IconClipboardCopy, IconFileText, IconSparkles, IconExternalLink, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams, useNavigate, NavLink, useSearchParams, } from "react-router"; import { toast } from "sonner"; import { EditableRecordingTitle } from "@/components/editable-recording-title"; import { EditorLayout } from "@/components/editor/editor-layout"; import { CommentsPanel } from "@/components/player/comments-panel"; import { RecordingOptionsMenu } from "@/components/player/delete-recording-menu"; import { InsightsPanel } from "@/components/player/insights-panel"; import { ReactionsTray } from "@/components/player/reactions-tray"; import { RecordingViewsBadge } from "@/components/player/recording-views-badge"; import { SettingsPanel } from "@/components/player/settings-panel"; import { ShareRecordingPopover } from "@/components/player/share-dialog"; import { TimestampedCommentButton, TimestampedCommentBar, } from "@/components/player/timestamped-comment-button"; import { TranscriptPanel } from "@/components/player/transcript-panel"; import { VideoPlayer, type VideoPlayerHandle, } from "@/components/player/video-player"; import { StorageSetupCard } from "@/components/recorder/storage-setup-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { isDefaultTitle, useAutoTitleBridge } from "@/hooks/use-auto-title"; import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts"; import { useViewTracking } from "@/hooks/use-view-tracking"; import enMessages from "@/i18n/en-US"; import { parsePlaybackSpeed } from "@/lib/playback-speed"; import { recordingShareUrl } from "@/lib/recording-link"; import { isStorageSetupFailureReason } from "@/lib/storage-failures"; import { cn } from "@/lib/utils"; import { STALE_PENDING_TRANSCRIPT_REASON } from "../../shared/transcript-status"; const UPLOAD_STUCK_TIMEOUT_MS = 5 * 60 * 1000; const PROCESSING_STUCK_TIMEOUT_MS = 12 * 60 * 1000; const READY_MEDIA_SETTLE_POLL_MS = 20 * 1000; const READY_MEDIA_SETTLE_POLL_INTERVAL_MS = 1000; export function meta() { return [{ title: enMessages.recordingRoute.pageTitle }]; } type SidePanel = "transcript" | "comments" | "insights" | "agent" | "settings"; type WorkflowKind = "pr" | "sop" | "ticket" | "email"; const WORKFLOW_MENU_ITEMS: Array<{ kind: WorkflowKind; labelKey: string; tooltipKey?: string; }> = [ { kind: "pr", labelKey: "recordingPage.generatePrSummary" }, { kind: "sop", labelKey: "recordingPage.generateSop", tooltipKey: "recordingPage.generateSopTooltip", }, { kind: "ticket", labelKey: "recordingPage.generateTicket" }, { kind: "email", labelKey: "recordingPage.generateEmail" }, ]; interface GeneratedWorkflowState { kind?: WorkflowKind; status?: "generating" | "ready" | "failed" | string; content?: string; recordingId?: string; requestedAt?: string; error?: string; } interface SilenceRemovalStatus { kind?: "remove-silences"; status?: "queued" | "working" | "completed" | "failed"; message?: string | null; updatedAt?: string; } function useIsCompactRecordingLayout() { const [isCompact, setIsCompact] = useState(false); useEffect(() => { const query = window.matchMedia("(max-width: 1279px)"); const update = () => setIsCompact(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); return isCompact; } function isNativeSaveFailureReason(reason: string | null | undefined): boolean { return /native recording upload|native fullscreen|screencapture|avconvert/i.test( reason ?? "", ); } function failureDetail(reason: string | null | undefined): string | null { const trimmed = reason?.trim(); if (!trimmed) return null; return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed; } function nativeSaveFailureMessage(reason: string | null | undefined): string { const text = reason ?? ""; if ( /finalization callback failed|missing required metadata|missing playback metadata|corrupted or incomplete|missing moov/i.test( text, ) ) { return "macOS could not finish writing this desktop recording. The local file is incomplete, so discard it from the Clips menu and record again."; } if (/too large|compression/i.test(text)) { return "Clips tried to compress this desktop recording, but it is still too large to upload. The original is saved locally and can be retried from the Clips menu."; } return "The desktop recorder finished and saved a local copy, but Clips could not upload it. You can retry from the Clips menu without recording again."; } function InsightsUnavailableState() { const t = useT(); return (

{t("sharePage.ownerInsights")}

{t("sharePage.ownerInsightsDescription")}

); } function parseTimeParam(raw: string | null): number { if (!raw) return 0; const value = raw.trim(); if (!value) return 0; if (/^\d+(\.\d+)?$/.test(value)) { return Math.floor(parseFloat(value) * 1000); } if (/^\d+:\d+(:\d+)?$/.test(value)) { const parts = value.split(":").map((part) => parseInt(part, 10)); if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000; if (parts.length === 3) { return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000; } } const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i); if (!match) return 0; const hours = parseInt(match[1] ?? "0", 10); const minutes = parseInt(match[2] ?? "0", 10); const seconds = parseInt(match[3] ?? "0", 10); return (hours * 3600 + minutes * 60 + seconds) * 1000; } export default function RecordingPage() { const t = useT(); useAutoTitleBridge(); const { recordingId } = useParams<{ recordingId: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const startMs = parseTimeParam(searchParams.get("t")); const panelParam = searchParams.get("panel"); const { session, isLoading: sessionLoading } = useSession(); const playerRef = useRef(null); const [panel, setPanel] = useState("agent"); const [theaterMode, setTheaterMode] = useState(false); const [editing, setEditing] = useState(false); const [currentMs, setCurrentMs] = useState(0); const [commentOpen, setCommentOpen] = useState(false); const [commentAtMs, setCommentAtMs] = useState(0); const isCompactLayout = useIsCompactRecordingLayout(); // Resolve the playback position for reactions/comments. Native