Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.

Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.

Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.

The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.

Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 09:15:12 -07:00
co-authored by Claude Opus 5
parent bd72781482
commit f68d9c5788
14 changed files with 1073 additions and 7 deletions
@@ -15,12 +15,16 @@ import {
getBrowserViewStatus,
installBrowserViewBrowser,
installBrowserViewSupport,
getBrowserViewMatchWindow,
getBrowserViewPopoutState,
openBrowserViewPopout,
openPageInContainerBrowser,
setBrowserViewEnabled,
setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection";
import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator";
@@ -80,6 +84,10 @@ export default function BrowserTab({ project, active }: Props) {
*/
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
const [onTop, setOnTop] = useState(false);
/** The "open a page" dialog, and the request it is running. */
const [matchWindow, setMatchWindow] = useState(false);
const [askPage, setAskPage] = useState(false);
const [openingPage, setOpeningPage] = useState(false);
const pushToast = useAppState((s) => s.pushToast);
const setContainerProgress = useAppState((s) => s.setContainerProgress);
const progress = useAppState((s) => s.containerProgress[project.id]);
@@ -137,6 +145,9 @@ export default function BrowserTab({ project, active }: Props) {
// 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));
getBrowserViewMatchWindow(projectId)
.then((on) => mounted.current && setMatchWindow(on))
.catch(() => {});
getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s))
.catch(() => {});
@@ -219,6 +230,55 @@ export default function BrowserTab({ project, active }: Props) {
[projectId, pushToast],
);
/**
* Open a URL in a browser inside the container.
*
* The pane only ever *watched* browsers something else published; this is the
* one action that opens one. It also means the page can be resized later —
* whoever launches a bound browser is the only process that can drive it.
*/
const openPage = useCallback(
async (url: string, width: number, height: number) => {
setOpeningPage(true);
try {
const result = await openPageInContainerBrowser(projectId, url, width, height);
if (!mounted.current) return;
setAskPage(false);
if (result.error) {
pushToast({ kind: "error", message: "The page didnt open", detail: result.error });
} else {
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
}
} catch (e) {
pushToast({
kind: "error",
message: "Could not open the page in the containers browser",
detail: String(e),
});
} finally {
if (mounted.current) setOpeningPage(false);
}
},
[projectId, pushToast],
);
const toggleMatchWindow = useCallback(
async (next: boolean) => {
setMatchWindow(next);
try {
await setBrowserViewMatchWindow(projectId, next);
} catch (e) {
if (mounted.current) setMatchWindow(!next);
pushToast({
kind: "error",
message: "Could not match the page to the window",
detail: String(e),
});
}
},
[projectId, pushToast],
);
/** Run one install. Every path clears the progress line it started. */
const install = useCallback(
async (which: Exclude<SetupJob, null>) => {
@@ -326,11 +386,25 @@ export default function BrowserTab({ project, active }: Props) {
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
</span>
)}
{live && poppedOut === true && (
<span
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
>
Match window
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
</span>
)}
{live && poppedOut === false && (
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
Reload
</Button>
)}
{live && (
<Button size="md" onClick={() => setAskPage(true)}>
Open a page
</Button>
)}
{live && poppedOut !== null && (
<Button size="md" onClick={poppedOut ? popIn : popOut}>
{poppedOut ? "Put back in tab" : "Open in own window"}
@@ -415,6 +489,14 @@ export default function BrowserTab({ project, active }: Props) {
)}
</div>
)}
{askPage && (
<OpenPageDialog
busy={openingPage}
onOpen={openPage}
onClose={() => setAskPage(false)}
/>
)}
</div>
);
}