fix: route sign-in links by what can actually catch the callback
`isAnthropicSignInUrl` made the container the default action for every Anthropic sign-in link, justified by "the host has nothing to catch it with". That was wrong in both directions. The host does have something -- the auth bridge -- and the container side is not a general browser at all but Playwright's dashboard, whose packages and chromium are deliberately not baked into the image. So the default pointed at the one path that is uninstalled on a fresh project, on every platform, while the path that works sat behind a switch. The decision now lives in `useSignInOpenTarget`: a live auth bridge picks the host, otherwise a container that can actually launch a browser picks the container, otherwise the host. It resolves at mount rather than when a URL arrives, so the buttons do not swap under a moving mouse, and it re-decides on `auth-bridge-changed` so flipping the switch during a hanging login takes effect. A bridge with port conflicts reads as not live; an empty `active_ports` does not, since there is nothing to bridge until the CLI binds its listener and that races the URL. Both buttons still render either way -- this changes which one leads. `sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in web_terminal/terminal.html needs no matching edit. The host "Open" path also failed silently: `dismissUrlPrompt()` ran before `openUrl`, so the toast vanished and a rejected promise reached only the devtools console. Dismissal now happens on success only, leaving "In container" one click away after a failure, and the error surfaces through the same toast the container path already used. On Linux this catch will not fire for the common case -- `xdg-open` routinely exits 0 having done nothing -- so it complements the AppImage environment fix rather than replacing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,18 @@ interface Props {
|
||||
/** 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;
|
||||
/**
|
||||
* 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";
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -57,17 +69,20 @@ interface Props {
|
||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||
* B that arrived a second later.
|
||||
*
|
||||
* ## Anthropic sign-in links default to the container's browser
|
||||
* ## Anthropic sign-in links get their default from the caller
|
||||
*
|
||||
* For an ordinary URL the host browser is the right answer and stays the
|
||||
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
|
||||
* is waiting on is inside the container, so a host browser completes the sign-in
|
||||
* and then posts the result somewhere nothing is listening, and the terminal
|
||||
* hangs until it times out. Making the host button primary there was quietly
|
||||
* steering every user into that. The container-side browser closes the loop
|
||||
* with no host round trip and no auth bridge, so it leads — and the host button
|
||||
* stays, because a user who has the auth bridge on, or who wants their existing
|
||||
* browser session, still needs it.
|
||||
* 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.
|
||||
*
|
||||
* ## Reachable without a mouse, and it does not take focus to manage it
|
||||
*
|
||||
@@ -96,6 +111,7 @@ export default function UrlToast({
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
signInDefault = "host",
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -103,18 +119,22 @@ export default function UrlToast({
|
||||
// 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);
|
||||
// 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";
|
||||
|
||||
// `Button` already owns the filled/outlined variants — including the rule
|
||||
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||
// foreground/link accent and fails WCAG AA behind white text.
|
||||
const hostButton = (
|
||||
<Button
|
||||
variant={signIn ? "secondary" : "primary"}
|
||||
data-url-toast-primary={signIn ? undefined : "true"}
|
||||
variant={containerLeads ? "secondary" : "primary"}
|
||||
data-url-toast-primary={containerLeads ? undefined : "true"}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
title={
|
||||
signIn
|
||||
containerLeads
|
||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||
: undefined
|
||||
}
|
||||
@@ -128,8 +148,8 @@ export default function UrlToast({
|
||||
// the container's own loopback, which is where the tool waiting for it is
|
||||
// listening — no host round trip, no auth bridge.
|
||||
<Button
|
||||
variant={signIn ? "primary" : "secondary"}
|
||||
data-url-toast-primary={signIn ? "true" : undefined}
|
||||
variant={containerLeads ? "primary" : "secondary"}
|
||||
data-url-toast-primary={containerLeads ? "true" : undefined}
|
||||
onClick={onOpenInContainer}
|
||||
className="flex-shrink-0"
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
@@ -235,14 +255,14 @@ export default function UrlToast({
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
Sign-in link — the callback listener is inside the container.
|
||||
Opening it there closes the loop; the host browser needs the auth
|
||||
bridge.
|
||||
{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."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signIn ? (
|
||||
{containerLeads ? (
|
||||
<>
|
||||
{containerButton}
|
||||
{hostButton}
|
||||
|
||||
Reference in New Issue
Block a user