2026-08-09 16:55:28 -07:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
|
|
|
|
|
import type {
|
2026-08-10 10:13:53 -07:00
|
|
|
|
BrowserInstallTarget,
|
|
|
|
|
|
BrowserSetupOutcome,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
BrowserViewChangedEvent,
|
2026-08-11 06:50:40 -07:00
|
|
|
|
BrowserViewPopoutChangedEvent,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
BrowserViewStatus,
|
2026-08-10 10:13:53 -07:00
|
|
|
|
PlaywrightDetection,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
Project,
|
|
|
|
|
|
} from "../../../lib/types";
|
|
|
|
|
|
import {
|
2026-08-10 10:13:53 -07:00
|
|
|
|
checkBrowserViewSupport,
|
2026-08-11 06:50:40 -07:00
|
|
|
|
closeBrowserViewPopout,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
getBrowserViewStatus,
|
2026-08-10 10:13:53 -07:00
|
|
|
|
installBrowserViewBrowser,
|
|
|
|
|
|
installBrowserViewSupport,
|
2026-08-11 07:40:51 -07:00
|
|
|
|
getBrowserViewPopoutState,
|
2026-08-11 06:50:40 -07:00
|
|
|
|
openBrowserViewPopout,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
setBrowserViewEnabled,
|
2026-08-11 06:50:40 -07:00
|
|
|
|
setBrowserViewPopoutAlwaysOnTop,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
} from "../../../lib/tauri-commands";
|
|
|
|
|
|
import { useAppState } from "../../../store/appState";
|
2026-08-10 10:13:53 -07:00
|
|
|
|
import AccordionSection from "../../ui/AccordionSection";
|
2026-08-09 16:55:28 -07:00
|
|
|
|
import Button from "../../ui/Button";
|
|
|
|
|
|
import StatusIndicator from "../../ui/StatusIndicator";
|
2026-08-11 06:50:40 -07:00
|
|
|
|
import Toggle from "../../ui/Toggle";
|
2026-08-09 16:55:28 -07:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-10 10:13:53 -07:00
|
|
|
|
/** Which install is in flight. `null` means none — nothing installs itself. */
|
|
|
|
|
|
type SetupJob = null | "packages" | BrowserInstallTarget;
|
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-08-10 10:13:53 -07:00
|
|
|
|
*
|
|
|
|
|
|
* 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.
|
2026-08-09 16:55:28 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export default function BrowserTab({ project, active }: Props) {
|
|
|
|
|
|
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
/** Bumped to force the iframe to reload without changing its src. */
|
|
|
|
|
|
const [reloadKey, setReloadKey] = useState(0);
|
2026-08-10 10:13:53 -07:00
|
|
|
|
/** Last read-only probe of the container, for the setup panel. */
|
|
|
|
|
|
const [detection, setDetection] = useState<PlaywrightDetection | null>(null);
|
|
|
|
|
|
const [job, setJob] = useState<SetupJob>(null);
|
|
|
|
|
|
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
|
|
|
|
|
const [setupError, setSetupError] = useState<string | null>(null);
|
2026-08-11 07:40:51 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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<boolean | null>(null);
|
2026-08-11 06:50:40 -07:00
|
|
|
|
const [onTop, setOnTop] = useState(false);
|
2026-08-09 16:55:28 -07:00
|
|
|
|
const pushToast = useAppState((s) => s.pushToast);
|
2026-08-10 10:13:53 -07:00
|
|
|
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
|
|
|
|
|
const progress = useAppState((s) => s.containerProgress[project.id]);
|
2026-08-09 16:55:28 -07:00
|
|
|
|
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<BrowserViewChangedEvent>("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]);
|
|
|
|
|
|
|
2026-08-11 06:50:40 -07:00
|
|
|
|
// 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<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
|
|
|
|
|
if (event.payload.project_id === projectId && mounted.current) {
|
|
|
|
|
|
setPoppedOut(event.payload.open);
|
2026-08-11 07:40:51 -07:00
|
|
|
|
setOnTop(event.payload.always_on_top);
|
2026-08-11 06:50:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
}).then((un) => {
|
|
|
|
|
|
if (mounted.current) dispose = un;
|
|
|
|
|
|
else un();
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => dispose?.();
|
|
|
|
|
|
}, [projectId]);
|
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!active || !running) return;
|
2026-08-11 07:40:51 -07:00
|
|
|
|
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));
|
2026-08-09 16:55:28 -07:00
|
|
|
|
getBrowserViewStatus(projectId)
|
|
|
|
|
|
.then((s) => mounted.current && setStatus(s))
|
|
|
|
|
|
.catch(() => {});
|
2026-08-10 10:13:53 -07:00
|
|
|
|
// 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(() => {});
|
2026-08-09 16:55:28 -07:00
|
|
|
|
}, [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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-11 06:50:40 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-10 10:13:53 -07:00
|
|
|
|
/** Run one install. Every path clears the progress line it started. */
|
|
|
|
|
|
const install = useCallback(
|
|
|
|
|
|
async (which: Exclude<SetupJob, null>) => {
|
|
|
|
|
|
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.
|
2026-08-09 16:55:28 -07:00
|
|
|
|
if (!running) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Explainer title="The container isn’t running.">
|
|
|
|
|
|
Start the container, have Claude drive a browser with Playwright, then come
|
|
|
|
|
|
back here to watch it.
|
|
|
|
|
|
</Explainer>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const live = status.state === "running" && status.url;
|
2026-08-10 10:13:53 -07:00
|
|
|
|
// 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);
|
2026-08-09 16:55:28 -07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col h-full min-h-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] flex-shrink-0 flex-wrap">
|
|
|
|
|
|
<StatusIndicator
|
|
|
|
|
|
tone={
|
|
|
|
|
|
busy
|
|
|
|
|
|
? "busy"
|
|
|
|
|
|
: status.state === "running"
|
|
|
|
|
|
? "running"
|
|
|
|
|
|
: status.state === "unavailable"
|
|
|
|
|
|
? "error"
|
|
|
|
|
|
: "off"
|
|
|
|
|
|
}
|
|
|
|
|
|
label={
|
|
|
|
|
|
busy
|
|
|
|
|
|
? "Starting"
|
|
|
|
|
|
: status.state === "running"
|
|
|
|
|
|
? "Live"
|
|
|
|
|
|
: status.state === "unavailable"
|
|
|
|
|
|
? "Unavailable"
|
|
|
|
|
|
: "Off"
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{live && (
|
|
|
|
|
|
<span className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
|
|
|
|
|
127.0.0.1:{status.host_port} → container :{status.container_port}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex-1" />
|
2026-08-11 07:40:51 -07:00
|
|
|
|
{live && poppedOut === true && (
|
|
|
|
|
|
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
2026-08-11 06:50:40 -07:00
|
|
|
|
Keep on top
|
2026-08-11 07:40:51 -07:00
|
|
|
|
{/* The accessible name matches the visible text, as everywhere else
|
|
|
|
|
|
a Toggle is used — a `<label>` around it would be inert anyway,
|
|
|
|
|
|
since a Toggle renders a button. */}
|
|
|
|
|
|
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
|
|
|
|
|
</span>
|
2026-08-11 06:50:40 -07:00
|
|
|
|
)}
|
2026-08-11 07:40:51 -07:00
|
|
|
|
{live && poppedOut === false && (
|
2026-08-09 16:55:28 -07:00
|
|
|
|
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
|
|
|
|
|
Reload
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-08-11 07:40:51 -07:00
|
|
|
|
{live && poppedOut !== null && (
|
2026-08-11 06:50:40 -07:00
|
|
|
|
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
|
|
|
|
|
{poppedOut ? "Put back in tab" : "Open in own window"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-08-09 16:55:28 -07:00
|
|
|
|
<Button
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
variant={live ? "secondary" : "primary"}
|
2026-08-10 10:13:53 -07:00
|
|
|
|
disabled={busy || job !== null}
|
2026-08-09 16:55:28 -07:00
|
|
|
|
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-11 07:40:51 -07:00
|
|
|
|
{live && poppedOut === true ? (
|
2026-08-11 06:50:40 -07:00
|
|
|
|
// 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.
|
|
|
|
|
|
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
|
|
|
|
|
<div className="max-w-[28rem] text-center">
|
|
|
|
|
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
|
|
|
|
|
This view is in its own window.
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
|
|
|
|
|
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.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mt-3 flex items-center justify-center gap-2">
|
|
|
|
|
|
<Button size="md" variant="primary" onClick={popIn}>
|
|
|
|
|
|
Put back in tab
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-11 07:40:51 -07:00
|
|
|
|
) : live && poppedOut === false ? (
|
2026-08-09 16:55:28 -07:00
|
|
|
|
<iframe
|
|
|
|
|
|
key={reloadKey}
|
|
|
|
|
|
// Loopback only, and the URL carries the one-time session token the
|
|
|
|
|
|
// host-side gate checks before anything reaches the container.
|
|
|
|
|
|
src={status.url ?? undefined}
|
|
|
|
|
|
title={`Playwright browser view for ${project.name}`}
|
|
|
|
|
|
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
|
|
|
|
|
/>
|
2026-08-11 07:40:51 -07:00
|
|
|
|
) : live ? (
|
|
|
|
|
|
// Live, but the window's state hasn't come back yet. An instant, and
|
|
|
|
|
|
// deliberately empty: guessing "not popped out" here is what would
|
|
|
|
|
|
// flash a second viewer onto the browser.
|
|
|
|
|
|
<div className="flex-1 min-h-0" />
|
2026-08-09 16:55:28 -07:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
2026-08-10 10:13:53 -07:00
|
|
|
|
{/* Setup stays on screen while an install is running and after it
|
|
|
|
|
|
finishes, so its output and caveats don't vanish at the moment
|
|
|
|
|
|
they become readable. */}
|
|
|
|
|
|
{needsSetup ||
|
|
|
|
|
|
status.state === "unavailable" ||
|
|
|
|
|
|
job !== null ||
|
|
|
|
|
|
outcome !== null ||
|
|
|
|
|
|
setupError !== null ? (
|
|
|
|
|
|
<Setup
|
|
|
|
|
|
detection={probed}
|
|
|
|
|
|
message={status.state === "unavailable" ? status.message : null}
|
|
|
|
|
|
job={job}
|
|
|
|
|
|
progress={job ? progress : undefined}
|
|
|
|
|
|
outcome={outcome}
|
|
|
|
|
|
error={setupError}
|
|
|
|
|
|
onInstall={install}
|
|
|
|
|
|
/>
|
2026-08-09 16:55:28 -07:00
|
|
|
|
) : error ? (
|
|
|
|
|
|
<Explainer title="The browser view didn’t start." tone="error">
|
|
|
|
|
|
<span className="font-mono text-xs break-words">{error}</span>
|
|
|
|
|
|
</Explainer>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Explainer title="Nothing is being watched yet.">
|
|
|
|
|
|
Start the view to run Playwright’s live dashboard inside this container
|
|
|
|
|
|
and mirror it here. You’ll see any browser a script has published with{" "}
|
|
|
|
|
|
<Code>await browser.bind('claude')</Code> — and{" "}
|
|
|
|
|
|
<Code>@playwright/mcp</Code> publishes automatically, so nothing extra is
|
|
|
|
|
|
needed if Claude is using that.
|
|
|
|
|
|
</Explainer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 10:13:53 -07:00
|
|
|
|
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
|
|
|
|
|
|
function isUsable(d: PlaywrightDetection | null): boolean {
|
|
|
|
|
|
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** What the container is short of, as a list rather than as prose. */
|
|
|
|
|
|
function missingParts(d: PlaywrightDetection | null): string[] {
|
|
|
|
|
|
if (!d) return [];
|
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
|
if (!d.node_version) out.push("Node.js");
|
|
|
|
|
|
if (!d.playwright_version) out.push("playwright");
|
|
|
|
|
|
else if (!d.has_bind) out.push("a newer playwright — this build has no browser.bind()");
|
|
|
|
|
|
if (!d.cli_entry) out.push("@playwright/cli");
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Setup, as one action per line, each saying what it costs before it is
|
|
|
|
|
|
* pressed.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The old pane printed npm commands here and left the rest to the user. The
|
|
|
|
|
|
* result, verified with a real one: an `@playwright/mcp` install that could
|
|
|
|
|
|
* never satisfy this pane, a global install that hit EACCES, a Chromium that
|
2026-08-10 10:57:21 -07:00
|
|
|
|
* downloaded and then would not start because the image shipped none of its
|
|
|
|
|
|
* shared libraries, and a long tail of commands after that. Current base images
|
|
|
|
|
|
* bake those libraries in, so that last one is fixed at the source — but a
|
|
|
|
|
|
* project keeps its original base image until it is migrated, so the install
|
|
|
|
|
|
* action still handles a container that lacks them.
|
2026-08-10 10:13:53 -07:00
|
|
|
|
*/
|
|
|
|
|
|
function Setup({
|
|
|
|
|
|
detection,
|
|
|
|
|
|
message,
|
|
|
|
|
|
job,
|
|
|
|
|
|
progress,
|
|
|
|
|
|
outcome,
|
|
|
|
|
|
error,
|
|
|
|
|
|
onInstall,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
detection: PlaywrightDetection | null;
|
|
|
|
|
|
message: string | null;
|
|
|
|
|
|
job: SetupJob;
|
|
|
|
|
|
progress?: string;
|
|
|
|
|
|
outcome: BrowserSetupOutcome | null;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
onInstall: (which: Exclude<SetupJob, null>) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const busy = job !== null;
|
|
|
|
|
|
const havePackages = isUsable(detection);
|
|
|
|
|
|
const missing = missingParts(detection);
|
|
|
|
|
|
const browsers = detection?.browsers ?? [];
|
|
|
|
|
|
const chrome = detection?.chrome_channel ?? null;
|
|
|
|
|
|
const noBrowser = browsers.length === 0 && chrome === null;
|
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
|
return (
|
2026-08-10 10:13:53 -07:00
|
|
|
|
<div className="p-4 max-w-[46rem] space-y-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
|
|
|
|
|
{!havePackages
|
|
|
|
|
|
? "This container can’t serve a browser view yet"
|
|
|
|
|
|
: noBrowser
|
|
|
|
|
|
? "Playwright is ready — but there’s no browser to drive yet"
|
|
|
|
|
|
: "This container is set up"}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
|
|
|
|
|
{message ??
|
|
|
|
|
|
(missing.length > 0
|
|
|
|
|
|
? `Missing: ${missing.join(", ")}.`
|
|
|
|
|
|
: noBrowser
|
|
|
|
|
|
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
|
|
|
|
|
: "Start the view from the button above once Claude has a browser open.")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Step
|
|
|
|
|
|
title="1. Playwright and the viewer UI"
|
|
|
|
|
|
detail={
|
|
|
|
|
|
<>
|
|
|
|
|
|
Installs <Code>playwright</Code> and <Code>@playwright/cli</Code> into{" "}
|
|
|
|
|
|
<Code>/workspace/node_modules</Code> inside the container. That directory is
|
|
|
|
|
|
container storage — your project folders are mounted one level down, so
|
|
|
|
|
|
nothing of yours is touched — and no <Code>sudo</Code> is involved. Small
|
|
|
|
|
|
download; browsers come next.
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
done={havePackages}
|
|
|
|
|
|
doneLabel={`Installed — playwright ${detection?.playwright_version ?? ""}, @playwright/cli ${detection?.cli_version ?? ""}`}
|
|
|
|
|
|
action={
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
variant={havePackages ? "secondary" : "primary"}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => onInstall("packages")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{job === "packages" ? "Installing…" : havePackages ? "Reinstall" : "Set up Playwright"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Step
|
|
|
|
|
|
title="2. A browser to drive"
|
|
|
|
|
|
detail={
|
|
|
|
|
|
<>
|
2026-08-10 10:57:21 -07:00
|
|
|
|
Both check the system libraries a browser links against first. Current base
|
|
|
|
|
|
images ship them, so that step is normally skipped; a container built from an
|
|
|
|
|
|
older image gets them installed with apt, which is the difference between a
|
|
|
|
|
|
browser that downloads successfully and one that also starts. Both end by
|
|
|
|
|
|
actually launching the browser to prove it works. Browsers land in{" "}
|
|
|
|
|
|
<Code>~/.cache/ms-playwright</Code>, which is on the home volume, so they
|
|
|
|
|
|
survive container recreation and are only lost on a project Reset.
|
2026-08-10 10:13:53 -07:00
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
done={browsers.length > 0 || chrome !== null}
|
|
|
|
|
|
doneLabel={[
|
|
|
|
|
|
browsers.length > 0 ? browsers.join(", ") : null,
|
|
|
|
|
|
chrome ? `Chrome channel (${chrome})` : null,
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" · ")}
|
|
|
|
|
|
action={
|
|
|
|
|
|
<div className="flex flex-col gap-2 items-end">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
variant={browsers.length > 0 || !havePackages ? "secondary" : "primary"}
|
|
|
|
|
|
disabled={busy || !havePackages}
|
|
|
|
|
|
onClick={() => onInstall("chromium")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{job === "chromium" ? "Installing…" : "Install Chromium"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
disabled={busy || !havePackages}
|
|
|
|
|
|
onClick={() => onInstall("chrome")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{job === "chrome" ? "Installing…" : "Install Chrome channel"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ul className="mt-2 space-y-1 text-xs text-[var(--text-secondary)] leading-relaxed">
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong className="text-[var(--text-primary)]">Chromium</strong> — Playwright’s
|
|
|
|
|
|
own build, used by <Code>chromium.launch()</Code> with no channel. Several
|
|
|
|
|
|
hundred MB.
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong className="text-[var(--text-primary)]">Chrome channel</strong> — Google
|
|
|
|
|
|
Chrome from apt, which is what <Code>@playwright/mcp</Code> asks for. Install
|
|
|
|
|
|
this one if Claude drives the browser through the MCP plugin. Roughly 150 MB.
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</Step>
|
|
|
|
|
|
|
|
|
|
|
|
{busy && (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="text-xs font-mono text-[var(--text-secondary)] break-all"
|
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
|
>
|
|
|
|
|
|
{progress ?? "Working…"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="text-xs text-[var(--error)]">
|
|
|
|
|
|
<p className="font-semibold">That didn’t work.</p>
|
|
|
|
|
|
<pre className="mt-1 whitespace-pre-wrap font-mono break-words text-[var(--text-secondary)]">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{outcome?.warning && (
|
|
|
|
|
|
<div className="text-xs text-[var(--text-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
|
|
|
|
|
<p className="font-semibold">Worth knowing</p>
|
|
|
|
|
|
<p className="mt-1 whitespace-pre-wrap text-[var(--text-secondary)] leading-relaxed">
|
|
|
|
|
|
{outcome.warning}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{outcome?.log && (
|
|
|
|
|
|
<AccordionSection
|
|
|
|
|
|
id="browser-view-install-log"
|
|
|
|
|
|
title="Install output"
|
|
|
|
|
|
defaultOpen={false}
|
|
|
|
|
|
>
|
|
|
|
|
|
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-64 overflow-y-auto">
|
|
|
|
|
|
{outcome.log}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</AccordionSection>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{detection && (
|
|
|
|
|
|
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-3 border-t border-[var(--border-color)]">
|
|
|
|
|
|
<Detail label="Node.js" value={detection.node_version} />
|
|
|
|
|
|
<Detail label="Playwright" value={detection.playwright_version} />
|
|
|
|
|
|
<Detail label="Resolved from" value={detection.playwright_path} />
|
|
|
|
|
|
<Detail
|
|
|
|
|
|
label="browser.bind()"
|
|
|
|
|
|
value={detection.has_bind ? "available" : "not in this build"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Detail label="@playwright/cli" value={detection.cli_version} />
|
|
|
|
|
|
<Detail
|
|
|
|
|
|
label="Browsers"
|
|
|
|
|
|
value={browsers.length > 0 ? browsers.join(", ") : null}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Detail label="Chrome channel" value={chrome} />
|
|
|
|
|
|
{detection.searched.length > 0 && (
|
|
|
|
|
|
<Detail label="Searched" value={detection.searched.join(", ")} />
|
2026-08-09 16:55:28 -07:00
|
|
|
|
)}
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 10:13:53 -07:00
|
|
|
|
/** One numbered setup step: what it does, whether it is done, and its button. */
|
|
|
|
|
|
function Step({
|
|
|
|
|
|
title,
|
|
|
|
|
|
detail,
|
|
|
|
|
|
done,
|
|
|
|
|
|
doneLabel,
|
|
|
|
|
|
action,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
detail: React.ReactNode;
|
|
|
|
|
|
done: boolean;
|
|
|
|
|
|
doneLabel?: string;
|
|
|
|
|
|
action: React.ReactNode;
|
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<h3 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
|
|
|
|
<StatusIndicator tone={done ? "ok" : "off"} label={done ? "Installed" : "Not installed"} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-relaxed">{detail}</p>
|
|
|
|
|
|
{done && doneLabel && (
|
|
|
|
|
|
<p className="mt-1 text-xs font-mono text-[var(--text-secondary)] break-all">
|
|
|
|
|
|
{doneLabel}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-shrink-0">{action}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
|
function Detail({ label, value }: { label: string; value: string | null }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<dt className="text-[var(--text-secondary)]">{label}</dt>
|
|
|
|
|
|
<dd className="font-mono text-[var(--text-primary)] break-all">
|
|
|
|
|
|
{value ?? "not found"}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Explainer({
|
|
|
|
|
|
title,
|
|
|
|
|
|
tone = "normal",
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
tone?: "normal" | "error";
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4 max-w-[46rem]">
|
|
|
|
|
|
<h2
|
|
|
|
|
|
className={`text-[13px] font-semibold ${
|
|
|
|
|
|
tone === "error" ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{title}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Code({ children }: { children: React.ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<code className="font-mono text-xs px-1 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|