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 { Button } from "@/components/ui/button"; import { Globe, LoaderCircle } from "lucide-react"; import { useBrowserViewport } from "@/hooks/useBrowserViewport"; import { TaskBrowserFooter } from "./TaskBrowserFooter"; export function TaskBrowserPanel({ issueId, browser, accessError = false, active = true, onOpenActiveBrowser, }: { issueId: string; browser?: TaskBrowser; accessError?: boolean; active?: boolean; onOpenActiveBrowser?: () => void; }) { const cache = useQueryClient(); const [url, setUrl] = useState(null); const [error, setError] = 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 = browser && ["running", "idle"].includes(browser.status) && !accessError; const viewport = useBrowserViewport({ issueId, browserId: browser?.id, enabled: Boolean(viewable && url && viewerLoad === "loaded"), active, frameRef, }); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); useEffect(() => { setUrl(null); setError(null); if (!viewable || !browser) return; let disposed = false; const controller = new AbortController(); // Keep the bearer viewer URL in component memory only. Revalidate even // when hidden so a revoked credential promptly unmounts its iframe. const load = () => { const version = viewport.version.current; return browserUseApi .viewer(issueId, browser.id, controller.signal, viewport.viewerId) .then((result) => { if (!disposed) { setUrl(result.url); setError(null); viewport.receive( result.viewportState ?? { preset: result.viewport ?? "fit" }, version, ); } }) .catch(() => { if (!disposed) { setUrl(null); setError( "The live view disconnected. Use Reconnect view in Browser options to try again.", ); } }); }; void load(); const timer = setInterval(() => { void load(); }, 15000); return () => { disposed = true; controller.abort(); clearInterval(timer); }; }, [issueId, browser?.id, 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: () => setError("The browser action failed. Try again."), }); const message = accessError ? "You no longer have access to this browser." : (browser?.error ?? error); 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" : error ? "Live view disconnected" : viewable ? "Connecting to browser…" : "No browser open"; const waiting = !message && (browser?.status === "starting" || browser?.status === "stopping" || viewable); return (
{viewable && url ? (