fix: do not let an in-flight open blank a newer prompt, or promise a bridge that is off

Two findings from review of this branch.

Awaiting the open instead of dismissing up front bought a window: on Linux
it is at least OPENER_GRACE, doubled when xdg-open fails and gio is tried.
If the container relays a second URL inside that window, the first open's
resolution blanked the second prompt -- losing a link that exists only in
the container's transcript, which is the failure "dismiss on success only"
was made to prevent. The slot already carried a `seq` for exactly this
reason; dismissal is now conditional on it.

`urlPromptRef` is written eagerly by the two functions that change the slot
rather than synced by an effect. That is load-bearing: an effect-synced
mirror lags state by a commit, and a promise microtask can resolve between
`setUrlPrompt` and React flushing passive effects -- so it answers "did a
newer prompt land?" wrong in precisely the window the guard exists for.
Dropping the functional updater also fixes `promptSeqRef.current += 1`
being mutated inside a state updater React is free to invoke twice.

The guard is a sibling function rather than an optional argument on
`dismissUrlPrompt`, because that function is passed by reference as
UrlToast's `onDismiss` and React would hand it a MouseEvent as its first
argument -- the seq check would fail and the close button would silently
stop working, with the types still assignable.

Separately, the sign-in hint was binary on which button leads, but "host
leads" covers both a live bridge and a fallback where nothing is set up to
catch the callback at all. In the second case the toast promised the bridge
would carry it and the login hung to its timeout. The target is now
three-state, the hint tells the truth in the fallback case and names the
control that fixes it, and the hook starts at `host-fallback` rather than
assuming a bridge it has not confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:12:22 -07:00
co-authored by Claude Opus 5
parent db648230ee
commit 60188610ee
5 changed files with 356 additions and 65 deletions
+47 -17
View File
@@ -14,8 +14,28 @@ import type {
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
/** Which of the URL toast's two buttons should lead for a sign-in link. */
export type SignInOpenTarget = "host" | "container";
/**
* Which of the URL toast's two buttons should lead for a sign-in link — and,
* for the host, *why*.
*
* Three states rather than two because "host" covers two worlds that are not
* the same promise to the user:
*
* - `host-bridged` — the auth bridge is live, so a sign-in completed in the
* user's own browser has its callback carried back to the listener inside
* the container. The host is genuinely the better answer here.
* - `container` — no bridge, but the container has a browser to open, which
* closes the loop locally with nothing crossing to the host.
* - `host-fallback` — neither. The host is the *least bad* of two answers
* that can both fail, and the toast has to say so: a hint claiming the
* bridge will carry the callback is a false promise in this state, and the
* user's `claude login` hangs to its timeout with nothing explaining why.
*
* Only `container` changes which button leads; the split between the two host
* states exists so the toast's hint can tell the truth. Keep it that way — the
* consumer that folds them back together is the bug this replaced.
*/
export type SignInOpenTarget = "host-bridged" | "container" | "host-fallback";
/**
* Whether the auth bridge can be relied on to catch a callback for this
@@ -42,16 +62,19 @@ export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
/**
* The rule, as a pure function of the two things it depends on.
*
* Both fallbacks land on the host, for different reasons:
* Both host answers land on the same button, for different reasons — and they
* are deliberately *not* the same value:
*
* - With the bridge live, the host browser is strictly better — it is the
* user's own signed-in profile, and the callback still reaches the container.
* - With neither available, the host is the *more likely to work* of two
* imperfect answers, and it is the one that reports its own failure (see
* `handleOpenUrl` in `TerminalView`). The container-side target is
* Playwright's dashboard pane, and Playwright's browsers are not baked into
* the image, so on a fresh project pointing there fails on every platform
* after a several-second wait.
* - With the bridge live (`host-bridged`), the host browser is strictly
* better — it is the user's own signed-in profile, and the callback still
* reaches the container.
* - With neither available (`host-fallback`), the host is the *more likely to
* work* of two imperfect answers, and it is the one that reports its own
* failure (see `handleOpenUrl` in `TerminalView`). The container-side target
* is Playwright's dashboard pane, and Playwright's browsers are not baked
* into the image, so on a fresh project pointing there fails on every
* platform after a several-second wait. Nothing carries the callback back in
* this state, so the toast says so rather than promising the bridge.
*
* Whichever way it goes, both buttons stay in the toast. This chooses which one
* leads, never which ones exist.
@@ -60,9 +83,9 @@ export function chooseSignInTarget(
bridge: AuthBridgeStatus | null,
detection: PlaywrightDetection | null,
): SignInOpenTarget {
if (authBridgeIsLive(bridge)) return "host";
if (authBridgeIsLive(bridge)) return "host-bridged";
if (canOpenPageInContainerBrowser(detection)) return "container";
return "host";
return "host-fallback";
}
/**
@@ -113,11 +136,16 @@ export function resetBrowserSupportCache(): void {
* bridge now on by default, is the ordinary case.
*/
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
const [target, setTarget] = useState<SignInOpenTarget>("host");
// `host-fallback` is the honest starting point, not `host-bridged`: before
// the status call answers, nothing is known to be carrying the callback, and
// the hint that claims one is the failure this three-state answer exists to
// prevent. Over-warning for the moment before the answer arrives costs a line
// of hedged text; under-warning costs a login that hangs to its timeout.
const [target, setTarget] = useState<SignInOpenTarget>("host-fallback");
useEffect(() => {
if (!projectId) {
setTarget("host");
setTarget("host-fallback");
return;
}
@@ -145,8 +173,10 @@ export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTa
.then((s) => {
if (!cancelled) consider(s);
})
// Nothing to say to the user here: this only picks which button is
// filled in, and the fallback is the one that reports its own failures.
// Nothing to say to the user here: an unanswered status call is fed
// through as a bridge that is off, which lands on `container` or
// `host-fallback` — and `host-fallback`'s hint is the one that tells the
// user the callback has nothing carrying it.
.catch(() => {
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
});