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-09-17 11:12:22 -07:00
import type { SignInOpenTarget } from "../../hooks/useSignInOpenTarget" ;
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
/**
2026-09-17 11:12:22 -07:00
* Which action leads for a *sign-in* link, and why (see the note below).
* Nothing else in the toast moves: both buttons are offered in all three
* states, in one of two orders.
2026-09-17 10:07:50 -07:00
*
* 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.
2026-09-17 11:12:22 -07:00
* `hooks/useSignInOpenTarget.ts` owns the rule.
*
* Two of the three lead with the host button and differ only in the hint,
* which is the whole point of carrying three: `"host-bridged"` may promise
* that the auth bridge brings the callback home, `"host-fallback"` may not,
* because in that state nothing does. `"host-fallback"` is the default for
* that reason — a caller that says nothing has not told us a bridge is live,
* and the hint must not invent one.
2026-09-17 10:07:50 -07:00
*/
2026-09-17 11:12:22 -07:00
signInDefault? : SignInOpenTarget ;
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
*
2026-09-17 11:12:22 -07:00
* The hint below the URL renders all *three* states, not the two orderings.
* "Neither is set up" also leads with the host, but it is not the same claim:
* there the callback has nothing carrying it, so the hint names what would fix
* that instead of describing a bridge that is off. A two-way hint keyed on
* which button leads is exactly how that false promise got shipped.
*
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 11:12:22 -07:00
signInDefault = "host-fallback" ,
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-09-17 11:12:22 -07:00
// The third state. Both host states put the same button first, so this is
// read by the hint alone: no bridge and no container browser means nothing is
// carrying the callback back, and saying "the auth bridge is what carries it"
// here is a promise the project cannot keep.
const hostIsLastResort = signIn && signInDefault === "host-fallback" ;
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."
2026-09-17 11:12:22 -07:00
: hostIsLastResort
? "Sign-in link — the callback listener is inside the container and nothing is set up to reach it. Turn on Auth bridge in the project’ s Config tab, or install browser support to sign in inside the container."
: "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 >
);
}