import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { BrowserViewChangedEvent, BrowserViewStatus, Project, } from "../../../lib/types"; import { getBrowserViewStatus, setBrowserViewEnabled, } from "../../../lib/tauri-commands"; import { useAppState } from "../../../store/appState"; 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, }; /** * 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. */ 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); const pushToast = useAppState((s) => s.pushToast); 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(() => {}); }, [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], ); // A stopped container can't be hosting a browser, so say that plainly rather // than offering a control 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; return (
{live && ( 127.0.0.1:{status.host_port} → container :{status.container_port} )}
{live && ( )}
{live ? (