import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { BrowserInstallTarget, BrowserSetupOutcome, BrowserViewChangedEvent, BrowserViewStatus, PlaywrightDetection, Project, } from "../../../lib/types"; import { checkBrowserViewSupport, getBrowserViewStatus, installBrowserViewBrowser, installBrowserViewSupport, setBrowserViewEnabled, } from "../../../lib/tauri-commands"; import { useAppState } from "../../../store/appState"; import AccordionSection from "../../ui/AccordionSection"; import Button from "../../ui/Button"; import StatusIndicator from "../../ui/StatusIndicator"; interface Props { project: Project; active: boolean; } const OFF: BrowserViewStatus = { enabled: false, state: "off", url: null, host_port: null, container_port: null, started_at: null, detection: null, message: null, }; /** Which install is in flight. `null` means none — nothing installs itself. */ type SetupJob = null | "packages" | BrowserInstallTarget; /** * Watch — and take over — the browser Claude is driving with Playwright inside * the container. * * The pane is an iframe onto Playwright's own live dashboard, which runs in the * container and is reached through a token-gated listener on the host's * loopback. Nothing starts until the user asks: this is remote control of a * browser in a privileged sandbox, so it is off by default and opted into per * project, exactly like the auth bridge. * * The same rule, harder, applies to setup. Opening this tab *probes* the * container (one `node -e`, read-only) so the pane can say what is missing * before the user asks for a view — but it never installs anything. Installing * packages and downloading a browser are container mutations measured in * hundreds of megabytes; both are separate, labelled, user-pressed buttons. */ export default function BrowserTab({ project, active }: Props) { const [status, setStatus] = useState(OFF); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); /** Bumped to force the iframe to reload without changing its src. */ const [reloadKey, setReloadKey] = useState(0); /** Last read-only probe of the container, for the setup panel. */ const [detection, setDetection] = useState(null); const [job, setJob] = useState(null); const [outcome, setOutcome] = useState(null); const [setupError, setSetupError] = useState(null); const pushToast = useAppState((s) => s.pushToast); const setContainerProgress = useAppState((s) => s.setContainerProgress); const progress = useAppState((s) => s.containerProgress[project.id]); const running = project.status === "running"; // The backend is the source of truth: it emits whenever a view starts or is // torn down (container stopped, project removed, viewer died). const projectId = project.id; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => { let dispose: (() => void) | undefined; listen("browser-view-changed", (event) => { if (event.payload.project_id === projectId && mounted.current) { setStatus(event.payload.status); } }).then((un) => { if (mounted.current) dispose = un; else un(); }); return () => dispose?.(); }, [projectId]); useEffect(() => { if (!active || !running) return; getBrowserViewStatus(projectId) .then((s) => mounted.current && setStatus(s)) .catch(() => {}); // Read-only. This is what lets the pane offer setup before the user hits a // wall, and it is why a "not installed" answer is never stale. checkBrowserViewSupport(projectId) .then((d) => mounted.current && setDetection(d)) .catch(() => {}); }, [active, projectId, running]); const toggle = useCallback( async (next: boolean) => { setBusy(true); setError(null); try { const result = await setBrowserViewEnabled(projectId, next); if (mounted.current) setStatus(result); } catch (e) { const detail = String(e); if (mounted.current) setError(detail); pushToast({ kind: "error", message: next ? "Could not start the browser view" : "Could not stop the browser view", detail, }); } finally { if (mounted.current) setBusy(false); } }, [projectId, pushToast], ); /** Run one install. Every path clears the progress line it started. */ const install = useCallback( async (which: Exclude) => { setJob(which); setSetupError(null); setOutcome(null); try { const result = which === "packages" ? await installBrowserViewSupport(projectId) : await installBrowserViewBrowser(projectId, which); if (!mounted.current) return; // The command re-probes, so the pane updates itself — no reopening the // tab, no second button to press. setDetection(result.detection); setOutcome(result); if (result.warning) { // Not an error — the step did what it said — but the caveat is the // part that decides whether the browser will actually work. pushToast({ kind: "info", message: "Setup finished, with something to know", detail: result.warning, }); } else { pushToast({ kind: "success", message: which === "packages" ? "Playwright installed" : `${which} installed and verified`, }); } } catch (e) { const detail = String(e); if (mounted.current) setSetupError(detail); pushToast({ kind: "error", message: "Setup failed", detail }); } finally { setContainerProgress(projectId, null); if (mounted.current) setJob(null); } }, [projectId, pushToast, setContainerProgress], ); // A stopped container can't be hosting a browser — and can't be installed // into either, so say that plainly rather than offering controls that would // only fail. if (!running) { return ( Start the container, have Claude drive a browser with Playwright, then come back here to watch it. ); } const live = status.state === "running" && status.url; // Prefer the probe: it is the fresher of the two, and it is the one that // reflects an install that just finished. const probed = detection ?? status.detection; const ready = isUsable(probed); // Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an // apt package, so it never shows up in `browsers`, and a container that has // it is not missing a browser. const needsBrowser = probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null; const needsSetup = probed !== null && (!ready || needsBrowser); return (
{live && ( 127.0.0.1:{status.host_port} → container :{status.container_port} )}
{live && ( )}
{live ? (