diff --git a/.changeset/fix-provider-login-dialog.md b/.changeset/fix-provider-login-dialog.md
new file mode 100644
index 0000000000..e8fee4fa59
--- /dev/null
+++ b/.changeset/fix-provider-login-dialog.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Show a persistent sign-in dialog during provider logins, with the paste field and status always visible.
+category: fix
+dev: New `ProviderLoginDialog` replaces the vanishing pre-flight confirm plus card-inline paste field for `requiresManualCode` OAuth flows. It is rendered as a SIBLING of the onboarding FloatingWindow (a portal moves the DOM node but not the React tree, so events bubbled to the window's raise-to-front handler and lifted it above the dialog), claims `nextFloatingZ()` once on open, and stops pointer propagation — now ratcheted for every portaled `.modal-overlay` in FloatingWindow.test.tsx. Spacing uses the shared `.modal-header`/`.modal-actions` primitives with `var(--modal-padding)` on every row; the paste field sinks to `var(--bg)` because `.form-input` and `.modal` both resolve to `var(--surface)`; the paste region is pinned outside the scroll area so Submit cannot scroll out of reach. Dialog anatomy rules documented in docs/dashboard-guide.md.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index c30090343e..3f16595c58 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -2038,6 +2038,31 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s
**Rule:** New CSS for a component goes in `app/components/ComponentName.css`, NOT `styles.css`. Only design tokens, primitives (`.btn`, `.card`, `.modal`, `.form-input`), and cross-component `@media` overrides belong in the global file.
+### Dialog anatomy: spacing and stacking
+
+Two rules that a new dialog gets wrong the same way every time. Both were paid for by the Set Up AI
+paste-back login dialog (2026-08-18): it shipped with insets that matched nothing else in the app,
+and it sank behind the modal that opened it.
+
+**Spacing comes from the primitives, not from your component.** A dialog panel is
+`
` with a `.modal-header` and a `.modal-actions` row; both already
+carry `var(--modal-padding)`. Give the middle region ONE inset from the same token and let its
+children sit flush inside it. Do not hand-roll header/action padding for a new dialog, and do not
+pad each child (steps, form, error line) separately — that is exactly how the login dialog's rows
+drifted out of alignment with each other and with every other dialog. Variant-specific overrides of
+the primitives (embedded, tablet, phone sheet) are legitimate and several exist; a brand-new dialog
+inventing its own base spacing is not.
+
+**A portaled dialog must be a sibling of any FloatingWindow it opens over, and must stop pointer
+propagation.** `createPortal` moves the DOM node to `` but NOT the React tree, so events raised
+inside the dialog still bubble to whatever component rendered it. Every FloatingWindow raises itself
+to a fresh `nextFloatingZ()` on pointerdown/focus, so a dialog portaled from inside a window's
+subtree lifts that window above itself on the first click — after which every click lands on the
+window behind. Render such a dialog outside the `` element (a sibling in the same
+fragment), claim `nextFloatingZ()` once on open like `ConfirmDialog` does, and `stopPropagation()`
+on the overlay's pointer/mouse/focus handlers. The last part is enforced for every portaled
+`.modal-overlay` by a ratchet in `packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx`.
+
### Browser-safe core imports
Dashboard browser code may value-import only browser-safe `@fusion/core` leaf modules. Vite aliases the package root to `packages/core/src/types.ts`; do not bypass that boundary with a relative `core/src` import unless the leaf is listed in [`scripts/lib/dashboard-browser-safe-core-modules.json`](../scripts/lib/dashboard-browser-safe-core-modules.json). In particular, use `near-duplicate-canonical.ts`, not `near-duplicate.ts`, because the latter reaches Node-only duplicate detection dependencies.
diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx
index edcb77725b..ffc7a554f5 100644
--- a/packages/dashboard/app/components/ModelOnboardingModal.tsx
+++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx
@@ -30,6 +30,7 @@ import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CursorCliProviderCard } from "./CursorCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { LoginInstructions } from "./LoginInstructions";
+import { ProviderLoginDialog, type ProviderLoginPhase } from "./ProviderLoginDialog";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { CustomProviderForm } from "./CustomProviderForm";
@@ -812,6 +813,17 @@ export function ModelOnboardingModal({
const [deviceCodes, setDeviceCodes] = useState>({});
const [manualCodeInputs, setManualCodeInputs] = useState>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState(null);
+ /* FNXC:ProviderAuth 2026-08-18-03:05: auth URL per in-flight login, so the dialog can re-open a lost sign-in tab. */
+ const [loginAuthUrls, setLoginAuthUrls] = useState>({});
+ /*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ Which provider's paste-back login dialog is open, and its terminal error if it failed. Visibility
+ is explicit state rather than derived from the in-flight flags, because the dialog must OUTLIVE the
+ flow on failure: the operator needs to read why it failed, and a toast is gone before they are back
+ from the browser tab they were signing in on.
+ */
+ const [loginDialogProvider, setLoginDialogProvider] = useState(null);
+ const [loginErrors, setLoginErrors] = useState>({});
const [availableModels, setAvailableModels] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
const [saving, setSaving] = useState(false);
@@ -1378,6 +1390,21 @@ export function ModelOnboardingModal({
if (!shouldContinue) {
return;
}
+ /*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ Hand the operator straight from the warning into the persistent dialog, so the paste field and
+ the flow's current step are on screen from the moment the browser tab opens — not buried in the
+ provider card below the fold of a scrolling modal.
+ */
+ setLoginErrors((prev) => {
+ if (!(providerId in prev)) {
+ return prev;
+ }
+ const next = { ...prev };
+ delete next[providerId];
+ return next;
+ });
+ setLoginDialogProvider(providerId);
}
// Clear any previous terminal outcome before starting a new login attempt
@@ -1396,6 +1423,20 @@ export function ModelOnboardingModal({
delete next[providerId];
lastAutoCopiedDeviceCodesRef.current = next;
}
+ /*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ The dialog's own state dies with the flow it belongs to. Clearing the auth URL is what
+ dismisses the dialog, so it must be cleared everywhere this teardown runs (success, cancel,
+ timeout, failure) or a finished login would leave a stuck modal over the dashboard.
+ */
+ setLoginAuthUrls((prev) => {
+ if (!(providerId in prev)) {
+ return prev;
+ }
+ const next = { ...prev };
+ delete next[providerId];
+ return next;
+ });
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
@@ -1448,6 +1489,13 @@ export function ModelOnboardingModal({
if (deviceCode && providerId === "github-copilot") {
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
}
+ /*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ Retain the auth URL so ProviderLoginDialog can re-open it. The sign-in tab is easy to lose
+ behind the dashboard or dismiss by accident, and without the URL the only recovery was to
+ cancel and restart the whole flow.
+ */
+ setLoginAuthUrls((prev) => ({ ...prev, [providerId]: appendTokenQuery(deviceCode?.verificationUri ?? url) }));
if (providerId !== "github-copilot" || !deviceCode) {
openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
}
@@ -1467,6 +1515,7 @@ export function ModelOnboardingModal({
provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
));
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
+ setLoginErrors((prev) => ({ ...prev, [providerId]: t("setup.loginTimedOut", "Login timed out. Please try again.") }));
clearAuthLoginUiState();
addToast(t("setup.loginTimedOut", "Login timed out. Please try again."), "warning");
return;
@@ -1486,6 +1535,7 @@ export function ModelOnboardingModal({
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
+ setLoginDialogProvider((current) => (current === providerId ? null : current));
clearAuthLoginUiState();
if (providerId === "github") {
setGitHubSkippedState(false);
@@ -1502,6 +1552,7 @@ export function ModelOnboardingModal({
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
+ setLoginErrors((prev) => ({ ...prev, [providerId]: t("setup.loginDidNotComplete", "Login did not complete. Please try again.") }));
clearAuthLoginUiState();
addToast(t("setup.loginDidNotComplete", "Login did not complete. Please try again."), "error");
}
@@ -1520,7 +1571,9 @@ export function ModelOnboardingModal({
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
void loadAuthStatus();
} else {
- addToast(err instanceof Error ? err.message : t("setup.loginFailed", "Login failed"), "error");
+ const failureText = err instanceof Error ? err.message : t("setup.loginFailed", "Login failed");
+ addToast(failureText, "error");
+ setLoginErrors((prev) => ({ ...prev, [providerId]: failureText }));
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
}
setAuthActionInProgress(null);
@@ -2415,13 +2468,25 @@ export function ModelOnboardingModal({
)}
- {(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && (
+ {/*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ Same rule as the paste field below: the dialog already shows these instructions, and rendering
+ them here too printed the same paragraph twice — once in the dialog and once in the card
+ visible around its edges.
+ */}
+ {(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && loginDialogProvider !== provider.id && (
)}
- {(authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && (
+ {/*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ The card keeps its own paste field only for flows the dialog is NOT showing (e.g. a remote
+ login adopted from another surface). Rendering both would put two inputs for the same code on
+ screen, one of them hidden behind the dialog.
+ */}
+ {(authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && loginDialogProvider !== provider.id && (
setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
@@ -2449,6 +2514,7 @@ export function ModelOnboardingModal({
};
return (
+ <>
)}
+
+
+ {/*
+ FNXC:ProviderAuth 2026-08-18-04:20:
+ RENDERED OUTSIDE THE FLOATINGWINDOW SUBTREE, DELIBERATELY. A portal relocates the DOM node but
+ not the React tree, and FloatingWindow raises itself to a fresh `nextFloatingZ()` on every
+ pointerdown/focus that reaches it — so while this lived inside the window's children, every
+ click in the dialog lifted the window above it and the following click landed on the window
+ instead. As a sibling, dialog events never reach the window's raise handler. Keep it here.
+ */}
+ {loginDialogProvider && (() => {
+ const dialogProvider = authProviders.find((entry) => entry.id === loginDialogProvider);
+ const manualCode = manualCodeConfigs[loginDialogProvider];
+ const failure = loginErrors[loginDialogProvider];
+ const phase: ProviderLoginPhase = dialogProvider?.authenticated
+ ? "succeeded"
+ : failure
+ ? "failed"
+ : manualCodeSubmitInProgress === loginDialogProvider
+ ? "submitting"
+ : "waiting";
+ return (
+ setManualCodeInputs((prev) => ({ ...prev, [loginDialogProvider]: value }))}
+ onSubmitCode={() => void handleSubmitManualCode(loginDialogProvider)}
+ onOpenAuthUrl={() => {
+ const url = loginAuthUrls[loginDialogProvider];
+ if (url) {
+ openExternalUrl(url);
+ }
+ }}
+ onCancel={() => {
+ const closing = loginDialogProvider;
+ setLoginDialogProvider(null);
+ // A still-running flow must be cancelled server-side, or its slot blocks the retry with a 409.
+ if (authActionInProgress === closing) {
+ void handleCancelLogin(closing);
+ }
+ }}
+ />
+ );
+ })()}
+ >
);
}
diff --git a/packages/dashboard/app/components/ProviderLoginDialog.css b/packages/dashboard/app/components/ProviderLoginDialog.css
new file mode 100644
index 0000000000..d3bd299867
--- /dev/null
+++ b/packages/dashboard/app/components/ProviderLoginDialog.css
@@ -0,0 +1,203 @@
+/*
+FNXC:ProviderAuth 2026-08-18-04:20:
+Styling for the persistent paste-back login dialog.
+
+SPACING COMES FROM THE SHARED PRIMITIVES. The header and action rows use `.modal-header` /
+`.modal-actions`, which already carry `var(--modal-padding)`; nothing here re-pads them. The body is
+the only region without a primitive, so it defines ONE inset from the same token and every child
+inherits it — the first version padded the header, the steps, the instructions, the paste form and
+the error line separately, which is how they drifted out of alignment with each other and with the
+rest of the app's dialogs.
+*/
+.provider-login-dialog {
+ width: min(calc(var(--space-xl) * 22), calc(100vw - var(--space-2xl)));
+ display: flex;
+ flex-direction: column;
+ /*
+ The dialog grows with the provider's instructions text, and `.modal`'s 80vh cap alone let the action
+ row fall off the bottom of a short window — the Cancel control was unreachable. Bound the panel and
+ scroll the body instead, keeping the header and actions pinned inside the viewport.
+ */
+ max-height: min(80vh, calc(100dvh - var(--space-2xl)));
+ overflow: hidden;
+}
+
+.provider-login-dialog__body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ padding: var(--modal-padding);
+ padding-top: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ min-height: 0;
+}
+
+.provider-login-dialog__steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+
+.provider-login-dialog__step {
+ display: flex;
+ gap: var(--space-sm);
+ align-items: flex-start;
+ color: var(--text-muted);
+}
+
+.provider-login-dialog__step-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--border);
+}
+
+/* The active step is the one the operator has to act on, so it carries the accent. */
+.provider-login-dialog__step--active .provider-login-dialog__step-icon {
+ color: var(--color-primary, var(--in-progress));
+}
+
+.provider-login-dialog__step--done .provider-login-dialog__step-icon {
+ color: var(--done, var(--color-success));
+}
+
+.provider-login-dialog__step--active .provider-login-dialog__step-body strong,
+.provider-login-dialog__step--done .provider-login-dialog__step-body strong {
+ color: var(--text);
+}
+
+.provider-login-dialog__step-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ min-width: 0;
+}
+
+.provider-login-dialog__step-body strong {
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.provider-login-dialog__step-body small {
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.provider-login-dialog__reopen {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ margin-top: var(--space-xs);
+}
+
+.provider-login-dialog__spinner {
+ animation: provider-login-dialog-spin var(--duration-slow, 1s) linear infinite;
+}
+
+@keyframes provider-login-dialog-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Nested surfaces span the body inset rather than adding one of their own. */
+.provider-login-dialog__body > .oauth-manual-code,
+.provider-login-dialog__body > .login-instructions,
+.provider-login-dialog__error {
+ margin: 0;
+}
+
+/*
+FNXC:ProviderAuth 2026-08-18-05:05:
+The pinned paste region sits between the scrolling body and the action row, so the field and its
+Submit stay on screen no matter how long the provider's instructions are or how short the viewport
+is. It carries the same inset as the body and a top divider to separate it from the scroll area.
+*/
+.provider-login-dialog__paste {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ /*
+ `var(--modal-padding)` — the SAME inset as .modal-header, the body, and .modal-actions. Using
+ --space-lg here instead left the paste box and its Submit button hanging closer to the panel edge
+ than every other row, which is what read as them being flush against it.
+ */
+ padding: var(--modal-padding);
+ border-top: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.provider-login-dialog__paste .oauth-manual-code {
+ margin-top: 0;
+}
+
+/*
+FNXC:ProviderAuth 2026-08-18-05:05:
+THE PASTE FIELD MUST READ AS A FIELD. `.form-input` fills with `var(--surface)` and outlines with
+`var(--border)` — and a `.modal` panel is ALSO `var(--surface)`, so inside a dialog the input's fill
+matches its background exactly and the only thing separating them is a near-black hairline
+(measured #0c0c0e on #0c0c0e with a #27272a border). It read as having no border at all, which
+matters more here than elsewhere because pasting into this box is the whole point of the dialog.
+Sink the field against the panel and strengthen its edge, both from existing tokens.
+*/
+.provider-login-dialog .oauth-manual-code__input {
+ background: var(--bg);
+ border-color: var(--border-strong);
+}
+
+/*
+FNXC:ProviderAuth 2026-08-18-05:05:
+`btn-sm` (4px 10px, ~25px tall) is a row-density control; as this dialog's primary commit action it
+was too small to read as the thing to press. Give it the standard control padding and make it the
+accented action, without touching the shared OAuthManualCodeForm used at row density in the cards.
+*/
+.provider-login-dialog .oauth-manual-code__actions {
+ gap: var(--space-sm);
+ margin-top: var(--space-xs);
+}
+
+.provider-login-dialog .oauth-manual-code__actions .btn {
+ padding: var(--space-sm) var(--space-lg);
+ font-size: 13px;
+}
+
+.provider-login-dialog__error {
+ line-height: 1.45;
+}
+
+/*
+FNXC:ProviderAuth 2026-08-18-04:20:
+The overlay dims the page. `.modal-overlay` is deliberately transparent for inline modals, but this
+dialog is a hard interruption of an OAuth flow — without a scrim it read as floating debris over the
+onboarding modal rather than the thing to act on.
+*/
+.provider-login-dialog-overlay {
+ background: color-mix(in srgb, var(--text) 35%, transparent);
+}
+
+@media (max-width: 768px), (max-height: 480px) {
+ .provider-login-dialog-overlay {
+ align-items: center;
+ padding: max(var(--space-md), env(safe-area-inset-top, 0px)) var(--space-sm)
+ max(var(--space-md), env(safe-area-inset-bottom, 0px));
+ }
+
+ .provider-login-dialog {
+ width: 100%;
+ max-height: calc(100dvh - var(--space-2xl));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .provider-login-dialog__spinner {
+ animation: none;
+ }
+}
diff --git a/packages/dashboard/app/components/ProviderLoginDialog.tsx b/packages/dashboard/app/components/ProviderLoginDialog.tsx
new file mode 100644
index 0000000000..af6e17b5e0
--- /dev/null
+++ b/packages/dashboard/app/components/ProviderLoginDialog.tsx
@@ -0,0 +1,201 @@
+import { useLayoutEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { CheckCircle2, ExternalLink, Loader2, X } from "lucide-react";
+import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
+import { LoginInstructions } from "./LoginInstructions";
+import { nextFloatingZ } from "./floatingWindowStack";
+import "./ProviderLoginDialog.css";
+
+/*
+FNXC:ProviderAuth 2026-08-18-03:05:
+A PASTE-BACK LOGIN MUST BE VISIBLE FOR ITS WHOLE DURATION, IN ONE PLACE.
+
+Before this dialog the flow scattered itself across three surfaces: a pre-flight confirm that warned
+about paste-back and then vanished, a provider card that changed to a small disabled "Waiting for
+login…" chip, and the paste field + instructions rendered inline INSIDE that card — below the fold of
+a scrolling modal, so the operator finished signing in, returned to the dashboard, and found no
+obvious place to put the redirect URL and no indication of what the app was waiting for.
+
+So the dialog opens when the login starts and STAYS until the flow ends: it names the step the flow
+is on, re-offers the sign-in URL (the popup is easy to lose behind the dashboard), always shows the
+paste field, and surfaces the terminal outcome inline instead of as a toast that disappears.
+
+FNXC:ProviderAuth 2026-08-18-04:20:
+LAYOUT CONTRACT — this dialog uses the shared `.modal-header` / `.modal-actions` primitives rather
+than hand-rolled padding. The first version set its own header/action padding and drifted from every
+other dialog in the app (reported as "doesn't have proper spacing"). Those primitives already carry
+`var(--modal-padding)`; only the step list, which has no primitive, defines its own inset, and it
+reuses the same token. Do not reintroduce bespoke padding on the header or action row here.
+*/
+
+export type ProviderLoginPhase = "waiting" | "submitting" | "failed" | "succeeded";
+
+export interface ProviderLoginDialogProps {
+ providerName: string;
+ /** Auth URL the flow opened, re-offered because the popup is easy to lose or dismiss. */
+ authUrl?: string;
+ instructions?: string;
+ phase: ProviderLoginPhase;
+ /** Terminal failure reason, shown inline; never a disappearing toast. */
+ errorMessage?: string;
+ manualCode: { prompt: string; placeholder?: string; helpText?: string };
+ codeValue: string;
+ onCodeChange: (value: string) => void;
+ onSubmitCode: () => void;
+ onOpenAuthUrl: () => void;
+ onCancel: () => void;
+ "data-testid"?: string;
+}
+
+const STEP_STATE = {
+ done: "provider-login-dialog__step--done",
+ active: "provider-login-dialog__step--active",
+ idle: "",
+} as const;
+
+export function ProviderLoginDialog({
+ providerName,
+ authUrl,
+ instructions,
+ phase,
+ errorMessage,
+ manualCode,
+ codeValue,
+ onCodeChange,
+ onSubmitCode,
+ onOpenAuthUrl,
+ onCancel,
+ "data-testid": testId,
+}: ProviderLoginDialogProps) {
+ /*
+ FNXC:ProviderAuth 2026-08-18-04:20:
+ Claim the top of the shared floating stack ONCE on open, the same way ConfirmDialog does. The first
+ version called `nextFloatingZ()` inline in the parent's JSX, which re-claimed on every render of a
+ modal that re-renders on a 2s auth poll — a side effect during render, and a number that changed
+ underneath the host window instead of settling above it.
+ */
+ const [overlayZ, setOverlayZ] = useState(undefined);
+ useLayoutEffect(() => {
+ setOverlayZ(nextFloatingZ());
+ }, []);
+
+ if (typeof document === "undefined") {
+ return null;
+ }
+
+ const signInState = phase === "waiting" ? STEP_STATE.active : STEP_STATE.done;
+ const exchangeState =
+ phase === "submitting" ? STEP_STATE.active : phase === "succeeded" ? STEP_STATE.done : STEP_STATE.idle;
+
+ return createPortal(
+
but NOT the React tree, so
+ events raised in here still bubble to whatever rendered it. This dialog is rendered by a
+ component hosted in a FloatingWindow, and that window raises itself to a fresh `nextFloatingZ()`
+ on every pointerdown/focus it sees — so each click INSIDE this dialog lifted the window above
+ it, and the next click landed on the window instead ("it keeps getting covered by the onboarding
+ dialog, any click goes to the dialog below"). The parent also renders this outside its
+ FloatingWindow subtree; this guard keeps the contract if that ever changes.
+ */
+ onPointerDown={(event) => event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ onFocus={(event) => event.stopPropagation()}
+ >
+
+
+
Signing in to {providerName}
+
+
+
+
+
+
+
+ {phase === "waiting" ? : }
+
+
+ Approve the sign-in in your browser
+
+ {phase === "waiting"
+ ? "A tab should have opened. Finish signing in there — this dialog stays put."
+ : "Authorization received."}
+
+ {authUrl && phase === "waiting" && (
+
+ )}
+
+
+
+
+
+ {phase === "submitting" ? : }
+
+
+ Hand the authorization back to Fusion
+
+ {phase === "submitting"
+ ? "Exchanging the authorization code…"
+ : phase === "succeeded"
+ ? "Connected."
+ : "Usually automatic. If your browser lands on an error page, paste that page's full URL below."}
+
+
+
+
+
+ {instructions && }
+
+
+ {/*
+ FNXC:ProviderAuth 2026-08-18-05:05:
+ PINNED, NOT SCROLLED. The paste field and its Submit are the dialog's reason to exist, so they
+ sit outside the scrolling body: with them inside it, a short viewport (or a provider with long
+ instructions) pushed Submit below the fold, leaving the operator holding a copied URL and no
+ visible way to hand it over. Only the steps and instructions scroll.
+ */}
+ {phase !== "succeeded" && (
+
,
+ document.body,
+ );
+}
diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
index 74511b1b48..30be4cf664 100644
--- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
+++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
@@ -1,3 +1,5 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { render, screen, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
@@ -240,6 +242,34 @@ describe("FloatingWindow", () => {
expect(cssRuleFor(floatingWindowCss, ".floating-window--chat.floating-window--headerless .floating-window__body")).toContain("overflow: hidden;");
});
+ /*
+ FNXC:FloatingWindow 2026-08-18-04:20:
+ RATCHET: a portaled `.modal-overlay` must swallow its own pointer events.
+
+ `createPortal` relocates the DOM node but NOT the React tree, so events raised inside a portaled
+ dialog still bubble to whichever component rendered it. Every FloatingWindow raises itself to a
+ fresh `nextFloatingZ()` on pointerdown/focus, so a dialog portaled from inside a window's subtree
+ lifts that window ABOVE itself on first click — after which clicks land on the window behind
+ (reported on the Set Up AI login dialog as "it keeps getting covered … any click goes to the dialog
+ below"). Two things prevent it: render the dialog as a SIBLING of the window, and stop propagation
+ at the overlay. This asserts the second for every such component, since the first is per-caller.
+ */
+ it("keeps every portaled modal overlay from leaking pointer events to its host", () => {
+ const componentsDir = resolve(__dirname, "..");
+ const offenders: string[] = [];
+ let checked = 0;
+
+ for (const file of readdirSync(componentsDir).filter((name) => name.endsWith(".tsx"))) {
+ const source = readFileSync(resolve(componentsDir, file), "utf8");
+ if (!source.includes("createPortal") || !source.includes("modal-overlay")) continue;
+ checked++;
+ if (!source.includes("stopPropagation")) offenders.push(file);
+ }
+
+ expect(checked, "expected portaled overlay components to scan").toBeGreaterThan(0);
+ expect(offenders, "portaled overlays must stop pointer propagation to their React-tree host").toEqual([]);
+ });
+
/*
FNXC:Onboarding 2026-08-17-23:47:
A FloatingWindow paints its own bordered surface, so a hosted child that does not fill it leaves
diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx
index f11b48a920..27e526bd98 100644
--- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx
@@ -618,6 +618,110 @@ describe("ModelOnboardingModal", () => {
});
});
+ /*
+ FNXC:ProviderAuth 2026-08-18-03:05:
+ A paste-back login must stay visible for its whole duration. Previously the pre-flight confirm
+ warned about paste-back and vanished, the card shrank to a disabled "Waiting for login…" chip, and
+ the paste field rendered inline below the fold of a scrolling modal — so an operator returning from
+ the browser had nowhere obvious to paste and no sign of what was being waited on.
+ */
+ describe("AI Setup step — persistent paste-back login dialog", () => {
+ async function startManualCodeLogin() {
+ // requiresManualCode is what routes a provider through the confirm + persistent dialog path.
+ mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
+ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
+ }));
+ mockLoginProvider.mockResolvedValue({
+ url: "https://claude.ai/oauth/authorize?state=abc",
+ instructions: "Complete login in your browser.",
+ manualCode: { prompt: "Paste the final redirect URL", placeholder: "http://localhost:*/callback?code=…" },
+ });
+ mockConfirm.mockResolvedValue(true);
+
+ render();
+ await waitFor(() => expect(screen.getByTestId("onboarding-provider-card-anthropic")).toBeTruthy());
+
+ const card = screen.getByTestId("onboarding-provider-card-anthropic");
+ const login = [...card.querySelectorAll("button")].find((b) => /^login$/i.test(b.textContent ?? ""));
+ fireEvent.click(login!);
+ return card;
+ }
+
+ it("keeps a dialog with the paste field open once the login starts", async () => {
+ await startManualCodeLogin();
+
+ const dialog = await screen.findByTestId("provider-login-dialog-anthropic");
+ expect(dialog).toBeTruthy();
+ // The paste target is in the dialog, not only inline in the card behind it.
+ expect(within(dialog).getByTestId("provider-login-dialog-manual-code")).toBeTruthy();
+ // And the flow's current step is stated rather than implied by a disabled chip.
+ expect(dialog.textContent).toMatch(/Approve the sign-in in your browser/);
+ expect(dialog.textContent).toMatch(/Hand the authorization back to Fusion/);
+ // A lost sign-in tab is recoverable without restarting the flow.
+ expect(within(dialog).getByRole("button", { name: /Open the sign-in page again/i })).toBeTruthy();
+ });
+
+ it("shows only one paste field for the flow", async () => {
+ await startManualCodeLogin();
+ await screen.findByTestId("provider-login-dialog-anthropic");
+
+ expect(screen.queryByTestId("onboarding-manual-code-anthropic")).toBeNull();
+ expect(screen.getAllByTestId("provider-login-dialog-manual-code")).toHaveLength(1);
+ // Instructions likewise appear once — the card's copy is suppressed while the dialog shows them.
+ expect(screen.queryByTestId("onboarding-login-instructions-anthropic")).toBeNull();
+ });
+
+ /*
+ FNXC:ProviderAuth 2026-08-18-04:20:
+ A portal moves the DOM node but NOT the React tree. While the dialog was rendered inside the
+ host FloatingWindow's children, every pointerdown in the dialog bubbled (through the React tree)
+ to the window's raise-to-front handler, which claimed a fresh nextFloatingZ() and painted the
+ window OVER the dialog — so the next click hit the window instead ("any click goes to the dialog
+ below"). The dialog must therefore be a SIBLING of the window, and must swallow pointer events.
+ */
+ it("does not let its pointer events reach the host floating window", async () => {
+ await startManualCodeLogin();
+ const dialog = await screen.findByTestId("provider-login-dialog-anthropic");
+
+ const hostWindow = document.querySelector(".floating-window");
+ expect(hostWindow, "onboarding still renders inside a FloatingWindow").toBeTruthy();
+ // Sibling, not descendant: containment is what allowed React-tree bubbling to the window.
+ expect(hostWindow!.contains(dialog)).toBe(false);
+
+ const windowPointerDown = vi.fn();
+ hostWindow!.addEventListener("pointerdown", windowPointerDown);
+ fireEvent.pointerDown(within(dialog).getByTestId("provider-login-dialog-manual-code"));
+ expect(windowPointerDown).not.toHaveBeenCalled();
+ });
+
+ it("uses the shared modal spacing primitives instead of bespoke padding", async () => {
+ await startManualCodeLogin();
+ const dialog = await screen.findByTestId("provider-login-dialog-anthropic");
+
+ // .modal-header / .modal-actions already carry var(--modal-padding); hand-rolled padding drifts.
+ expect(dialog.querySelector(".provider-login-dialog > .modal-header")).toBeTruthy();
+ expect(dialog.querySelector(".provider-login-dialog > .modal-actions")).toBeTruthy();
+ });
+
+ it("keeps the dialog open on failure so the reason can be read", async () => {
+ mockConfirm.mockResolvedValue(true);
+ mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
+ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
+ }));
+ mockLoginProvider.mockRejectedValue(new Error("Login initiation timed out"));
+
+ render();
+ await waitFor(() => expect(screen.getByTestId("onboarding-provider-card-anthropic")).toBeTruthy());
+ const card = screen.getByTestId("onboarding-provider-card-anthropic");
+ fireEvent.click([...card.querySelectorAll("button")].find((b) => /^login$/i.test(b.textContent ?? ""))!);
+
+ const dialog = await screen.findByTestId("provider-login-dialog-anthropic");
+ await waitFor(() => {
+ expect(within(dialog).getByTestId("provider-login-dialog-error").textContent).toMatch(/Login initiation timed out/);
+ });
+ });
+ });
+
describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => {
render();