import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { BrowserInstallTarget, BrowserSetupOutcome, BrowserViewChangedEvent, BrowserViewPopoutChangedEvent, BrowserViewStatus, PlaywrightDetection, Project, } from "../../../lib/types"; import { checkBrowserViewSupport, closeBrowserViewPopout, getBrowserViewStatus, installBrowserViewBrowser, installBrowserViewSupport, getBrowserViewPopoutState, openBrowserViewPopout, setBrowserViewEnabled, setBrowserViewPopoutAlwaysOnTop, } from "../../../lib/tauri-commands"; import { useAppState } from "../../../store/appState"; import AccordionSection from "../../ui/AccordionSection"; import Button from "../../ui/Button"; import StatusIndicator from "../../ui/StatusIndicator"; import Toggle from "../../ui/Toggle"; 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); /** * Whether the view is in its own window instead of this pane, and whether * that window is pinned. `null` means "not asked yet" — a distinct state from * "not popped out", because rendering the iframe on a guess is what puts a * second viewer on the browser. */ const [poppedOut, setPoppedOut] = useState(null); const [onTop, setOnTop] = useState(false); 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]); // The window is the backend's, not this component's: it survives the tab // being closed, the pane being unmounted and the view being torn down from // elsewhere. So its state is listened for, never assumed. useEffect(() => { let dispose: (() => void) | undefined; listen("browser-view-popout-changed", (event) => { if (event.payload.project_id === projectId && mounted.current) { setPoppedOut(event.payload.open); setOnTop(event.payload.always_on_top); } }).then((un) => { if (mounted.current) dispose = un; else un(); }); return () => dispose?.(); }, [projectId]); useEffect(() => { if (!active || !running) return; getBrowserViewPopoutState(projectId) .then((s) => { if (!mounted.current) return; setPoppedOut(s.open); setOnTop(s.always_on_top); }) // Unreachable in practice, but a pane stuck at "not asked yet" would // never show the view at all — so fail towards the tab. .catch(() => mounted.current && setPoppedOut(false)); 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], ); /** * Pop the view out, or pull it back. * * Both are window operations only — the viewer keeps running either way — so * this is cheap enough to toggle freely and never interrupts what the agent * is doing in the browser. */ const popOut = useCallback(async () => { try { await openBrowserViewPopout(projectId, onTop); if (mounted.current) setPoppedOut(true); } catch (e) { pushToast({ kind: "error", message: "Could not open the browser in its own window", detail: String(e), }); } }, [projectId, onTop, pushToast]); const popIn = useCallback(async () => { try { await closeBrowserViewPopout(projectId); if (mounted.current) setPoppedOut(false); } catch (e) { pushToast({ kind: "error", message: "Could not close the browser window", detail: String(e), }); } }, [projectId, pushToast]); const toggleOnTop = useCallback( async (next: boolean) => { setOnTop(next); try { await setBrowserViewPopoutAlwaysOnTop(projectId, next); } catch (e) { if (mounted.current) setOnTop(!next); pushToast({ kind: "error", message: "Could not change the window's stacking", detail: String(e), }); } }, [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.chrome_channel === null && (probed.browsers.length === 0 || revisionSkew(probed)); const needsSetup = probed !== null && (!ready || needsBrowser); return (
{live && ( 127.0.0.1:{status.host_port} → container :{status.container_port} )}
{live && poppedOut === true && ( Keep on top {/* The accessible name matches the visible text, as everywhere else a Toggle is used — a ` )} {live && poppedOut === false && ( )} {live && poppedOut !== null && ( )}
{live && poppedOut === true ? ( // The iframe is unmounted while the window is up, on purpose. Two // viewers on one browser both work, but both also *drive* it — two // cursors taking over the same page is not a feature.

This view is in its own window.

Move it to another screen, or keep it on top, and watch the browser while you work here. The view keeps running either way — closing the window brings it back into this tab.

) : live && poppedOut === false ? (