import { useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { type BrowserUseControl, type TaskBrowser } from "@paperclipai/shared"; import { browserUseApi } from "@/api/browser-use"; import { QueryErrorState, QueryStaleHint } from "@/components/QueryView"; import { Button } from "@/components/ui/button"; import { Globe, LoaderCircle } from "lucide-react"; import { useBrowserViewport } from "@/hooks/useBrowserViewport"; import { useResilientFetch } from "@/hooks/useResilientFetch"; import { TaskBrowserFooter } from "./TaskBrowserFooter"; /** Revalidate the viewer credential this often, even while the panel is hidden. */ const VIEWER_REVALIDATION_MS = 15_000; export function TaskBrowserPanel({ issueId, browser, accessError = false, active = true, onOpenActiveBrowser, }: { issueId: string; browser?: TaskBrowser; accessError?: boolean; active?: boolean; onOpenActiveBrowser?: () => void; }) { const cache = useQueryClient(); const [actionError, setActionError] = useState(null); const [refresh, setRefresh] = useState(0); const automaticReconnects = useRef(0); const reconnect = () => { automaticReconnects.current = 0; setRefresh((n) => n + 1); }; const [viewerLoad, setViewerLoad] = useState< "loading" | "loaded" | "timed-out" >("loading"); const [now, setNow] = useState(Date.now()); const frameRef = useRef(null); const viewable = Boolean( browser && ["running", "idle"].includes(browser.status) && !accessError, ); const browserId = browser?.id; // The viewport hook needs the viewer URL and the viewer read needs the // viewport's version and viewer id; the ref breaks the cycle. const viewportRef = useRef | null>(null); // The bearer viewer URL lives in this component's memory only, never in a // shared or persisted cache. It is revalidated even while hidden, so a // revoked credential (a real 403/404) promptly unmounts its iframe, while a // transient failure keeps the loaded view on screen. const viewer = useResilientFetch({ key: ["task-browser-viewer", issueId, browserId ?? null, refresh], enabled: viewable && Boolean(browserId), refetchInterval: VIEWER_REVALIDATION_MS, keepDataOnError: false, fetcher: async (signal) => { const version = viewportRef.current?.version.current ?? 0; const result = await browserUseApi.viewer( issueId, browserId!, signal, viewportRef.current?.viewerId, ); return { ...result, version }; }, onSuccess: (result) => { viewportRef.current?.receive( result.viewportState ?? { preset: result.viewport ?? "fit" }, result.version, ); }, }); const url = viewer.view.kind === "ready" || viewer.view.kind === "stale" ? viewer.data?.url ?? null : null; const viewerError = viewer.view.kind === "error" ? viewer.error : null; const viewport = useBrowserViewport({ issueId, browserId, enabled: Boolean(viewable && url && viewerLoad === "loaded"), active, frameRef, }); viewportRef.current = viewport; useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); useEffect(() => { setActionError(null); }, [issueId, browserId, viewable, refresh]); useEffect(() => { setViewerLoad("loading"); }, [url, refresh]); useEffect(() => { automaticReconnects.current = 0; }, [browser?.id]); useEffect(() => { // A hidden iframe may not navigate until shown. Give the selected viewer // a full load window, then reconnect once before asking the user to act. if (!active || !url || viewerLoad !== "loading") return; const timer = setTimeout(() => { if (automaticReconnects.current === 0) { automaticReconnects.current += 1; setRefresh((n) => n + 1); } else { setViewerLoad("timed-out"); } }, 15_000); return () => clearTimeout(timer); }, [active, url, refresh, viewerLoad]); const [presenceError, setPresenceError] = useState(false); useEffect(() => { if (!active || !viewable || !url || !browser || browser.status !== "idle") return; let disposed = false; let pending = false; const renew = async () => { const frame = frameRef.current; if ( pending || document.visibilityState === "hidden" || !frame || !frame.getClientRects().length || (frame.checkVisibility && !frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, })) ) return; pending = true; try { const result = await browserUseApi.presence(issueId, browser.id); if (!disposed && result.accepted) setPresenceError(false); } catch { if (!disposed) setPresenceError(true); } finally { pending = false; } }; void renew(); const timer = setInterval(() => void renew(), 30_000); document.addEventListener("visibilitychange", renew); return () => { disposed = true; clearInterval(timer); document.removeEventListener("visibilitychange", renew); }; }, [active, viewable, url, browser?.id, browser?.status, issueId]); const action = useMutation({ mutationFn: (value: BrowserUseControl) => browserUseApi.control(issueId, browser!.id, value), onSuccess: () => { void cache.invalidateQueries({ queryKey: ["task-browsers", issueId] }); }, onError: () => setActionError("The browser action failed. Try again."), }); const message = accessError ? "You no longer have access to this browser." : (browser?.error ?? actionError); const heading = accessError ? "Browser access unavailable" : browser?.status === "starting" ? "Starting browser…" : browser?.status === "stopping" ? "Closing browser…" : browser?.status === "closed" ? "Browser closed" : browser?.status === "failed" ? "Browser could not start" : viewable ? "Connecting to browser…" : "No browser open"; const waiting = !message && (browser?.status === "starting" || browser?.status === "stopping" || viewable); return (
{viewable && viewerError ? ( // A real failure (the credential was revoked, the browser is gone): // the iframe is already unmounted, so say so and offer a retry.
) : viewable && url ? (