2026-08-23 11:11:43 -07:00
|
|
|
import type { KeyboardEvent } from "react";
|
2026-08-23 08:31:39 -07:00
|
|
|
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
2026-08-23 11:11:43 -07:00
|
|
|
import Button from "../ui/Button";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Marks the toast's subtree. `TerminalView` uses it to answer "is focus inside
|
|
|
|
|
* the thing I am about to unmount?", which is what decides whether dismissing
|
|
|
|
|
* has to hand focus back to the terminal.
|
|
|
|
|
*/
|
|
|
|
|
export const URL_TOAST_SELECTOR = '[data-testid="url-toast"]';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The chord that jumps from the terminal into this toast.
|
|
|
|
|
*
|
|
|
|
|
* Bound in `TerminalView` on `document` in the capture phase, the same way
|
|
|
|
|
* `useKeyboardShortcuts` binds the app's other chords, because xterm would
|
|
|
|
|
* otherwise forward it to the shell. Shift is what keeps it clear of the
|
|
|
|
|
* terminal: plain Ctrl+O is readline's `operate-and-get-next`.
|
|
|
|
|
*/
|
|
|
|
|
export const URL_TOAST_SHORTCUT = "Ctrl+Shift+O";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Marks the *default* action inside the toast, so the owner can put focus
|
|
|
|
|
* there without a ref threaded through `ui/Button` — which is a plain function
|
|
|
|
|
* component and not this file's to change. Which button it is depends on the
|
|
|
|
|
* URL (see the sign-in note below), so the attribute moves with the decision
|
|
|
|
|
* rather than the caller having to repeat it.
|
|
|
|
|
*/
|
|
|
|
|
export const URL_TOAST_PRIMARY_SELECTOR = '[data-url-toast-primary="true"]';
|
2026-08-09 19:35:39 -07:00
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
interface Props {
|
2026-08-09 19:35:39 -07:00
|
|
|
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
2026-03-01 08:29:43 -08:00
|
|
|
url: string;
|
2026-08-09 16:55:28 -07:00
|
|
|
/** Heading above the URL. Says why the toast appeared. */
|
|
|
|
|
label?: string;
|
2026-03-01 08:29:43 -08:00
|
|
|
onOpen: () => void;
|
2026-08-11 09:15:12 -07:00
|
|
|
/** 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;
|
2026-09-17 10:07:50 -07:00
|
|
|
/**
|
|
|
|
|
* Which action leads for a *sign-in* link (see the note below). Nothing else
|
|
|
|
|
* in the toast moves: both buttons are offered either way, in either order.
|
|
|
|
|
*
|
|
|
|
|
* This component does not work it out, because the answer depends on the
|
|
|
|
|
* project's auth bridge and on what is installed inside its container —
|
|
|
|
|
* neither of which a presentational component should be reaching for.
|
|
|
|
|
* `hooks/useSignInOpenTarget.ts` owns the rule. `"host"` is the default here
|
|
|
|
|
* for the same reason it is the fallback there: it is the answer that is more
|
|
|
|
|
* likely to work, and the one that reports its own failure.
|
|
|
|
|
*/
|
|
|
|
|
signInDefault?: "host" | "container";
|
2026-03-01 08:29:43 -08:00
|
|
|
onDismiss: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
/**
|
|
|
|
|
* 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.
|
2026-08-23 08:31:39 -07:00
|
|
|
*
|
2026-09-17 10:07:50 -07:00
|
|
|
* ## Anthropic sign-in links get their default from the caller
|
2026-08-23 08:31:39 -07:00
|
|
|
*
|
|
|
|
|
* For an ordinary URL the host browser is the right answer and stays the
|
2026-09-17 10:07:50 -07:00
|
|
|
* default, unconditionally. A sign-in is the one case where it might not be:
|
|
|
|
|
* the callback listener the CLI is waiting on is inside the container, so a
|
|
|
|
|
* host browser can complete the sign-in and then post the result where nothing
|
|
|
|
|
* is listening, leaving the terminal to hang to its timeout.
|
|
|
|
|
*
|
|
|
|
|
* *Can*, not *does* — which is why this is no longer decided from the URL. The
|
|
|
|
|
* auth bridge mirrors that container listener onto the same host port, and the
|
|
|
|
|
* container-side alternative is Playwright's dashboard pane, which a fresh
|
|
|
|
|
* project has not installed. Both of those are project facts, so the owner
|
|
|
|
|
* passes {@link Props.signInDefault} and this only renders it: the leading
|
|
|
|
|
* button is filled and comes first, the other keeps its place beside it.
|
2026-08-23 11:11:43 -07:00
|
|
|
*
|
|
|
|
|
* ## Reachable without a mouse, and it does not take focus to manage it
|
|
|
|
|
*
|
|
|
|
|
* This toast is the only route to completing a sign-in started in a terminal,
|
|
|
|
|
* and it used to be mouse-only: xterm's helper textarea swallows Tab, so there
|
|
|
|
|
* was no way to reach these buttons at all from the keyboard.
|
|
|
|
|
*
|
|
|
|
|
* The obvious fix — focus the default action when the toast appears — was
|
|
|
|
|
* rejected on two counts. The terminal underneath is *live*: the user may be
|
|
|
|
|
* mid-command, and every keystroke after the steal would go to a button instead
|
|
|
|
|
* of the shell. Worse, the default action opens a URL chosen by the untrusted
|
|
|
|
|
* side of the sandbox, and a focused button is one stray Space or Enter away
|
|
|
|
|
* from doing it. This prompt exists precisely to make that a deliberate act.
|
|
|
|
|
*
|
|
|
|
|
* So focus stays where the user put it and the toast is reachable on demand:
|
|
|
|
|
* {@link URL_TOAST_SHORTCUT} jumps to the default action (the hint is on
|
|
|
|
|
* screen, next to the label, because a shortcut nobody is told about is not a
|
|
|
|
|
* route), Tab then moves between the actions normally — this subtree is not
|
|
|
|
|
* inside xterm — and Escape dismisses. Escape is handled *here*, on the
|
|
|
|
|
* toast's own subtree, rather than globally: Escape belongs to whatever is
|
|
|
|
|
* running in the terminal, and a document-level binding for it would break vim
|
|
|
|
|
* for everyone who never looked at this toast.
|
2026-08-09 19:35:39 -07:00
|
|
|
*/
|
2026-08-09 16:55:28 -07:00
|
|
|
export default function UrlToast({
|
|
|
|
|
url,
|
|
|
|
|
label = "Long URL detected",
|
|
|
|
|
onOpen,
|
2026-08-11 09:15:12 -07:00
|
|
|
onOpenInContainer,
|
2026-09-17 10:07:50 -07:00
|
|
|
signInDefault = "host",
|
2026-08-09 16:55:28 -07:00
|
|
|
onDismiss,
|
|
|
|
|
}: Props) {
|
2026-08-09 19:35:39 -07:00
|
|
|
const origin = urlOrigin(url);
|
|
|
|
|
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
2026-08-23 08:31:39 -07:00
|
|
|
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
|
|
|
|
// host button is the only action there is, so it stays primary.
|
|
|
|
|
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
2026-09-17 10:07:50 -07:00
|
|
|
// A sign-in link the caller has decided is better completed inside the
|
|
|
|
|
// container. Everything below keys off this rather than off `signIn`, so the
|
|
|
|
|
// two orderings differ only in which of the pair leads.
|
|
|
|
|
const containerLeads = signIn && signInDefault === "container";
|
2026-08-23 08:31:39 -07:00
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
// `Button` already owns the filled/outlined variants — including the rule
|
|
|
|
|
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
2026-08-23 08:31:39 -07:00
|
|
|
// foreground/link accent and fails WCAG AA behind white text.
|
|
|
|
|
const hostButton = (
|
2026-08-23 11:11:43 -07:00
|
|
|
<Button
|
2026-09-17 10:07:50 -07:00
|
|
|
variant={containerLeads ? "secondary" : "primary"}
|
|
|
|
|
data-url-toast-primary={containerLeads ? undefined : "true"}
|
2026-08-23 08:31:39 -07:00
|
|
|
onClick={onOpen}
|
2026-08-23 11:11:43 -07:00
|
|
|
className="flex-shrink-0"
|
2026-08-23 08:31:39 -07:00
|
|
|
title={
|
2026-09-17 10:07:50 -07:00
|
|
|
containerLeads
|
2026-08-23 08:31:39 -07:00
|
|
|
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Open
|
2026-08-23 11:11:43 -07:00
|
|
|
</Button>
|
2026-08-23 08:31:39 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const containerButton = 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.
|
2026-08-23 11:11:43 -07:00
|
|
|
<Button
|
2026-09-17 10:07:50 -07:00
|
|
|
variant={containerLeads ? "primary" : "secondary"}
|
|
|
|
|
data-url-toast-primary={containerLeads ? "true" : undefined}
|
2026-08-23 08:31:39 -07:00
|
|
|
onClick={onOpenInContainer}
|
2026-08-23 11:11:43 -07:00
|
|
|
className="flex-shrink-0"
|
2026-08-23 08:31:39 -07:00
|
|
|
title="Open in a browser inside the container, and watch it in the Browser tab"
|
|
|
|
|
>
|
|
|
|
|
In container
|
2026-08-23 11:11:43 -07:00
|
|
|
</Button>
|
2026-08-23 08:31:39 -07:00
|
|
|
);
|
2026-08-09 19:35:39 -07:00
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
if (e.key !== "Escape") return;
|
|
|
|
|
// Scoped to this subtree, so the terminal's own Escape is untouched.
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onDismiss();
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="animate-slide-down"
|
2026-08-23 11:11:43 -07:00
|
|
|
data-testid="url-toast"
|
2026-08-09 19:35:39 -07:00
|
|
|
role="status"
|
2026-08-23 11:11:43 -07:00
|
|
|
aria-atomic="true"
|
|
|
|
|
aria-keyshortcuts="Control+Shift+O"
|
|
|
|
|
onKeyDown={onKeyDown}
|
2026-03-01 08:29:43 -08:00
|
|
|
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,
|
2026-08-23 11:11:43 -07:00
|
|
|
boxShadow: "var(--shadow-overlay)",
|
2026-03-01 08:29:43 -08:00
|
|
|
maxWidth: "min(90%, 600px)",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: "var(--text-secondary)",
|
|
|
|
|
marginBottom: 2,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-08-09 16:55:28 -07:00
|
|
|
{label}
|
2026-08-23 11:11:43 -07:00
|
|
|
{" · "}
|
|
|
|
|
<span data-testid="url-toast-shortcut" style={{ fontFamily: "monospace" }}>
|
|
|
|
|
{URL_TOAST_SHORTCUT}
|
|
|
|
|
</span>{" "}
|
|
|
|
|
to reach the buttons, Esc to dismiss
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
|
|
|
|
<div
|
2026-08-09 19:35:39 -07:00
|
|
|
data-testid="url-toast-url"
|
|
|
|
|
title={url}
|
2026-03-01 08:29:43 -08:00
|
|
|
style={{
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontFamily: "monospace",
|
|
|
|
|
color: "var(--text-primary)",
|
2026-08-09 19:35:39 -07:00
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "baseline",
|
|
|
|
|
minWidth: 0,
|
2026-03-01 08:29:43 -08:00
|
|
|
}}
|
|
|
|
|
>
|
2026-08-09 19:35:39 -07:00
|
|
|
{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>
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
2026-08-23 08:31:39 -07:00
|
|
|
{signIn && (
|
|
|
|
|
<div
|
|
|
|
|
data-testid="url-toast-signin-hint"
|
|
|
|
|
style={{
|
|
|
|
|
marginTop: 3,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: "var(--text-secondary)",
|
|
|
|
|
lineHeight: 1.35,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-09-17 10:07:50 -07:00
|
|
|
{containerLeads
|
|
|
|
|
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
|
|
|
|
|
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
|
2026-08-23 08:31:39 -07:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
|
|
|
|
|
2026-09-17 10:07:50 -07:00
|
|
|
{containerLeads ? (
|
2026-08-23 08:31:39 -07:00
|
|
|
<>
|
|
|
|
|
{containerButton}
|
|
|
|
|
{hostButton}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{hostButton}
|
|
|
|
|
{containerButton}
|
|
|
|
|
</>
|
2026-08-11 09:15:12 -07:00
|
|
|
)}
|
|
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
2026-03-01 08:29:43 -08:00
|
|
|
onClick={onDismiss}
|
2026-08-23 11:11:43 -07:00
|
|
|
className="flex-shrink-0"
|
2026-03-01 08:29:43 -08:00
|
|
|
aria-label="Dismiss"
|
2026-08-23 11:11:43 -07:00
|
|
|
title="Dismiss (Esc)"
|
2026-03-01 08:29:43 -08:00
|
|
|
>
|
|
|
|
|
✕
|
2026-08-23 11:11:43 -07:00
|
|
|
</Button>
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|