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 =
2026-08-11 09:04:06 -07:00
probed !== null &&
probed . chrome_channel === null &&
( probed . browsers . length === 0 || revisionSkew ( probed ));
2026-08-10 10:13:53 -07:00
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 ( & apos ; claude & apos ;)</ 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 ;
}
2026-08-11 09:04:06 -07:00
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
* Browsers are installed, but not the revision one of the two Playwright copies
* would launch — so the cache looks full and launches fail. A probe that didn't
* answer leaves the executable null, and "unknown" must not read as "broken".
*/
function revisionSkew ( d : PlaywrightDetection | null ) : boolean {
if ( ! d || d . browsers . length === 0 ) return false ;
// `!= null`, not `!== null`: a probe from a container that predates these
// fields omits them entirely, and `undefined` is "didn't answer" — which must
// never render as "your browsers are wrong".
const viewerBroken = d . chromium_executable != null && ! d . chromium_executable_exists ;
const scriptsBroken =
d . script_chromium_executable != null && ! d . script_chromium_executable_exists ;
return viewerBroken || scriptsBroken ;
}
/**
* The skew sentence, naming both halves.
*
* "Install a browser" over a cache that visibly already holds one reads as
* nonsense, so the copy has to say which copy of Playwright wants what.
*/
function skewText ( d : PlaywrightDetection | null ) : string {
if ( ! d ) return "" ;
const scriptsBroken =
d . script_chromium_executable !== null && ! d . script_chromium_executable_exists ;
const [ version , wanted ] = scriptsBroken
? [ d . script_playwright_version , d . script_chromium_executable ]
: [ d . playwright_version , d . chromium_executable ];
return (
`This container has ${ d . browsers . join ( ", " ) } , but ` +
` ${ scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer" } ` +
` — ${ version ?? "?" } — launches ${ wanted ?? "?" } , which isn’ t there. ` +
( scriptsBroken
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
: "Install Chromium below: it runs that build’ s own installer, so it fetches exactly the revision that is missing." )
);
}
2026-08-10 10:13:53 -07:00
/** 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-11 09:04:06 -07:00
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
// because the fix is the same button but the sentence must not be "install a
// browser" over a cache that visibly has one.
const skew = revisionSkew ( detection ) && chrome === null ;
2026-08-10 10:13:53 -07:00
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"
2026-08-11 09:04:06 -07:00
: skew
? "The installed browser isn’ t the one Playwright launches"
: noBrowser
? "Playwright is ready — but there’ s no browser to drive yet"
: "This container is set up" }
2026-08-10 10:13:53 -07:00
</ h2 >
< p className = "mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed" >
{ message ??
( missing . length > 0
? `Missing: ${ missing . join ( ", " ) } .`
2026-08-11 09:04:06 -07:00
: skew
? skewText ( detection )
: 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." )}
2026-08-10 10:13:53 -07:00
</ 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 >
);
}