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
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:
@@ -18,6 +18,10 @@ const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||
const getBrowserViewPopoutState =
|
||||
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const openPageInContainerBrowser =
|
||||
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
|
||||
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
|
||||
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
|
||||
const pushToast = vi.fn();
|
||||
const setContainerProgress = vi.fn();
|
||||
|
||||
@@ -32,6 +36,10 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
|
||||
openPageInContainerBrowser(id, url, w, h),
|
||||
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
|
||||
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -130,6 +138,9 @@ beforeEach(() => {
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
@@ -494,6 +505,58 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "http://localhost:5173" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||
});
|
||||
|
||||
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"http://localhost:5173",
|
||||
1920,
|
||||
1080,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "file:///etc/passwd" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers match-window only once the view is in its own window", async () => {
|
||||
await renderLive();
|
||||
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
@@ -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 didn’t 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 container’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../../ui/Modal";
|
||||
import Button from "../../ui/Button";
|
||||
|
||||
/**
|
||||
* Viewport presets. These are the *page's* resolution, not the window's — the
|
||||
* pane is a screencast, so a bigger window shows the same pixels drawn larger
|
||||
* while this is what actually reflows the layout.
|
||||
*/
|
||||
const PRESETS: { label: string; width: number; height: number }[] = [
|
||||
{ label: "1280 × 720", width: 1280, height: 720 },
|
||||
{ label: "1920 × 1080", width: 1920, height: 1080 },
|
||||
{ label: "1440 × 900", width: 1440, height: 900 },
|
||||
{ label: "390 × 844 (phone)", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
|
||||
initialUrl?: string;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
busy?: boolean;
|
||||
onOpen: (url: string, width: number, height: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a URL and a viewport, then open it in the container's browser.
|
||||
*
|
||||
* Deliberately modal and short-lived — the convention for a task with one
|
||||
* question and one button. The URL is not opened here; the caller runs the
|
||||
* command so failures land in its toast.
|
||||
*/
|
||||
export default function OpenPageDialog({
|
||||
initialUrl = "",
|
||||
initialWidth = 1280,
|
||||
initialHeight = 720,
|
||||
busy = false,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
const [height, setHeight] = useState(initialHeight);
|
||||
|
||||
const trimmed = url.trim();
|
||||
// Mirrors the backend's allow-list, so the error arrives before the click
|
||||
// rather than after a round trip.
|
||||
const valid = /^https?:\/\/\S+$/i.test(trimmed);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Open a page in the container's browser"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!valid || busy}
|
||||
onClick={() => onOpen(trimmed, width, height)}
|
||||
>
|
||||
{busy ? "Opening…" : "Open page"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
Launches a browser <em>inside</em> this container and publishes it to the
|
||||
Browser tab. Use it for a sign-in page — the callback listener is in the
|
||||
container too, so the login completes without involving your host browser —
|
||||
or for a dev server on container loopback.
|
||||
</p>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--text-secondary)]">URL</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
|
||||
}}
|
||||
placeholder="http://localhost:5173"
|
||||
spellCheck={false}
|
||||
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
{trimmed !== "" && !valid && (
|
||||
<span className="mt-1 block text-xs text-[var(--error)]">
|
||||
Only http:// and https:// URLs can be opened.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((p) => {
|
||||
const active = p.width === width && p.height === height;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setWidth(p.width);
|
||||
setHeight(p.height);
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
|
||||
active
|
||||
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
|
||||
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Viewport width"
|
||||
value={width}
|
||||
min={200}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Viewport height"
|
||||
value={height}
|
||||
min={200}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import {
|
||||
awsSsoRefresh,
|
||||
openPageInContainerBrowser,
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import {
|
||||
@@ -529,6 +533,46 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt]);
|
||||
|
||||
/**
|
||||
* Open the prompted URL in the container's own browser instead of the host's.
|
||||
*
|
||||
* For a sign-in this is the shorter path: the callback listener the tool is
|
||||
* waiting on is inside the container, so a container-side browser closes the
|
||||
* loop with nothing crossing to the host. The page is published to the
|
||||
* project's Browser tab, which is where the user completes it by hand.
|
||||
*/
|
||||
const handleOpenUrlInContainer = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
if (!projectId) return;
|
||||
// A sign-in page is the one case where the *window* size matters least and
|
||||
// the layout matters most, so it gets the ordinary desktop viewport.
|
||||
openPageInContainerBrowser(projectId, safe, 1280, 720)
|
||||
.then((result) => {
|
||||
const push = useAppState.getState().pushToast;
|
||||
if (result.error) {
|
||||
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||
} else {
|
||||
push({
|
||||
kind: "success",
|
||||
message: "Opened in the container’s browser — see the project’s Browser tab",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open it in the container’s browser",
|
||||
detail: String(e),
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, projectId]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
@@ -606,6 +650,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
url={urlPrompt.url}
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
onDismiss={() => setUrlPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,9 @@ interface Props {
|
||||
/** Heading above the URL. Says why the toast appeared. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||
* the project has no browser to open it in. */
|
||||
onOpenInContainer?: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ export default function UrlToast({
|
||||
url,
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -131,6 +135,30 @@ export default function UrlToast({
|
||||
Open
|
||||
</button>
|
||||
|
||||
{onOpenInContainer && (
|
||||
// A sign-in completed in the *container's* browser lands its callback
|
||||
// on the container's own loopback, which is where the tool waiting for
|
||||
// it is listening — no host round trip, no auth bridge.
|
||||
<button
|
||||
onClick={onOpenInContainer}
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
In container
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -222,6 +222,39 @@ export const closeBrowserViewPopout = (projectId: string) =>
|
||||
*/
|
||||
export const getBrowserViewPopoutState = (projectId: string) =>
|
||||
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
|
||||
/**
|
||||
* Open a URL in a browser *inside* the container, published so the pane shows it.
|
||||
*
|
||||
* The same action serves an auth URL — the OAuth callback listener is in the
|
||||
* container too, so the loop closes without the host — and a dev server on
|
||||
* container loopback, which is how you watch a UI Claude is building. Only
|
||||
* http/https; the backend rejects anything else.
|
||||
*/
|
||||
export const openPageInContainerBrowser = (
|
||||
projectId: string,
|
||||
url: string,
|
||||
width: number,
|
||||
height: number,
|
||||
) => invoke<BrowserPageState>("open_page_in_container_browser", { projectId, url, width, height });
|
||||
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
|
||||
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
|
||||
invoke<void>("set_container_page_viewport", { projectId, width, height });
|
||||
export const getContainerPageState = (projectId: string) =>
|
||||
invoke<BrowserPageState>("get_container_page_state", { projectId });
|
||||
export const closeContainerPage = (projectId: string) =>
|
||||
invoke<void>("close_container_page", { projectId });
|
||||
|
||||
/**
|
||||
* Make the page track the pop-out window's size as it is dragged.
|
||||
*
|
||||
* Only affects a page this app opened: a bound browser admits no second client,
|
||||
* so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||
*/
|
||||
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
|
||||
invoke<void>("set_browser_view_match_window", { projectId, enabled });
|
||||
export const getBrowserViewMatchWindow = (projectId: string) =>
|
||||
invoke<boolean>("get_browser_view_match_window", { projectId });
|
||||
|
||||
/** Pin the pop-out above other windows — the point of popping it out at all. */
|
||||
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
||||
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
||||
|
||||
@@ -535,6 +535,23 @@ export interface BrowserViewPopoutState {
|
||||
always_on_top: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors Rust `page::Viewport` — CSS pixels, clamped backend-side. */
|
||||
export interface BrowserPageViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `page::PageState`: what the container-side helper reports about
|
||||
* the page Triple-C opened. `ready: false` with no error means there is none.
|
||||
*/
|
||||
export interface BrowserPageState {
|
||||
ready: boolean;
|
||||
url: string | null;
|
||||
viewport: BrowserPageViewport | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState`
|
||||
* plus the project it belongs to.
|
||||
|
||||
Reference in New Issue
Block a user