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>
188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import { urlOrigin } from "../../lib/urlRelay";
|
|
|
|
interface Props {
|
|
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
|
url: string;
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Confirmation prompt for a URL something inside the container wants opened in
|
|
* the host browser.
|
|
*
|
|
* The origin is rendered separately from the rest of the URL and is never
|
|
* truncated. A single `nowrap`/`ellipsis` line looks tidy but is a spoofing
|
|
* primitive: `https://accounts.example.com/....(600 chars)....@evil.tld/` shows
|
|
* the reassuring half and hides the half that decides where the request goes.
|
|
* `sanitizeRelayUrl` already rejects the userinfo form; showing the origin in
|
|
* full is the belt to that braces, and it also covers the plainer case of a
|
|
* long path pushing the host out of view.
|
|
*
|
|
* Render this with a `key` that changes whenever the URL does. The prompt slot
|
|
* is shared and long-lived, so without one React mutates the node in place: the
|
|
* text swaps with no animation, and a user reading URL A can click Open on URL
|
|
* B that arrived a second later.
|
|
*/
|
|
export default function UrlToast({
|
|
url,
|
|
label = "Long URL detected",
|
|
onOpen,
|
|
onOpenInContainer,
|
|
onDismiss,
|
|
}: Props) {
|
|
const origin = urlOrigin(url);
|
|
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
|
|
|
return (
|
|
<div
|
|
className="animate-slide-down"
|
|
role="status"
|
|
style={{
|
|
position: "absolute",
|
|
top: 12,
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
zIndex: 40,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
padding: "8px 12px",
|
|
background: "var(--bg-secondary)",
|
|
border: "1px solid var(--border-color)",
|
|
borderRadius: 8,
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
maxWidth: "min(90%, 600px)",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-secondary)",
|
|
marginBottom: 2,
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
<div
|
|
data-testid="url-toast-url"
|
|
title={url}
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: "monospace",
|
|
color: "var(--text-primary)",
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{origin && (
|
|
<span
|
|
data-testid="url-toast-origin"
|
|
style={{
|
|
fontWeight: 700,
|
|
// The part that decides where the credentials go. It wraps
|
|
// rather than truncates, whatever else has to give.
|
|
flexShrink: 0,
|
|
overflowWrap: "anywhere",
|
|
}}
|
|
>
|
|
{origin}
|
|
</span>
|
|
)}
|
|
<span
|
|
data-testid="url-toast-rest"
|
|
style={{
|
|
color: "var(--text-secondary)",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{rest}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={onOpen}
|
|
style={{
|
|
padding: "4px 12px",
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: "#fff",
|
|
background: "var(--accent)",
|
|
border: "none",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
whiteSpace: "nowrap",
|
|
flexShrink: 0,
|
|
}}
|
|
onMouseEnter={(e) =>
|
|
(e.currentTarget.style.background = "var(--accent-hover)")
|
|
}
|
|
onMouseLeave={(e) =>
|
|
(e.currentTarget.style.background = "var(--accent)")
|
|
}
|
|
>
|
|
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={{
|
|
padding: "2px 6px",
|
|
fontSize: 14,
|
|
lineHeight: 1,
|
|
color: "var(--text-secondary)",
|
|
background: "transparent",
|
|
border: "none",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
flexShrink: 0,
|
|
}}
|
|
onMouseEnter={(e) =>
|
|
(e.currentTarget.style.color = "var(--text-primary)")
|
|
}
|
|
onMouseLeave={(e) =>
|
|
(e.currentTarget.style.color = "var(--text-secondary)")
|
|
}
|
|
aria-label="Dismiss"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|