Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/browser/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const NO_ANIMATION: React.CSSProperties = { animation: "none" };

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
Expand All @@ -56,6 +58,8 @@ const DialogContent = React.forwardRef<
* cancel an inline edit) instead of dismissing the dialog.
*/
allowEditableEscape?: boolean;
/** Open without the entry animation, when the same frame is already on screen. */
skipEntryAnimation?: boolean;
}
>(
(
Expand All @@ -66,14 +70,15 @@ const DialogContent = React.forwardRef<
maxWidth,
maxHeight,
allowEditableEscape = false,
skipEntryAnimation = false,
style,
onEscapeKeyDown,
...props
},
ref
) => (
<DialogPortal>
<DialogOverlay />
<DialogOverlay style={skipEntryAnimation ? NO_ANIMATION : undefined} />
<DialogPrimitive.Content
ref={ref}
onEscapeKeyDown={(e) => {
Expand Down Expand Up @@ -101,7 +106,12 @@ const DialogContent = React.forwardRef<
maxHeight && "overflow-y-auto",
className
)}
style={{ ...(maxWidth && { maxWidth }), ...(maxHeight && { maxHeight }), ...style }}
style={{
...(maxWidth && { maxWidth }),
...(maxHeight && { maxHeight }),
...(skipEntryAnimation && NO_ANIMATION),
...style,
}}
{...props}
>
{children}
Expand Down
46 changes: 38 additions & 8 deletions src/browser/features/SplashScreens/OnboardingWizardSplash.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Bot,
Expand All @@ -9,7 +9,7 @@
Sparkles,
} from "lucide-react";
import { SplashScreen } from "./SplashScreen";
import { useOnboardingPause } from "./SplashScreenProvider";
import { hasFirstRunView, removeFirstRunView, useOnboardingPause } from "./SplashScreenProvider";
import { DocsLink } from "@/browser/components/DocsLink/DocsLink";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon/ProviderIcon";
import {
Expand Down Expand Up @@ -193,983 +193,1013 @@
);
}

/**
* T3 #5971, C step 2: the server's static first-run view (#xum-pre) already shows the gateway step.
* The live wizard renders nothing while it loads under that view, then swaps it out in its first
* real commit (a layout effect runs before paint). The dialog and overlay never play their entry
* animation in this mount: they stay open, so clearing the override later would replay it. The
* step skips its entry animation only until the user moves on. A wizard that starts past the
* gateway step replaces the view too.
*/
function useFirstRunViewHandoff(isLoading: boolean) {
const [startsUnderFirstRunView] = useState(hasFirstRunView);
const [skipStepEntry, setSkipStepEntry] = useState(startsUnderFirstRunView);
useLayoutEffect(() => {
if (!isLoading) removeFirstRunView();
}, [isLoading]);
return {
hideWhileLoading: isLoading && startsUnderFirstRunView,
skipDialogEntry: startsUnderFirstRunView,
skipStepEntry,
stopSkipping: () => setSkipStepEntry(false),
};
}

function stepEntryClassName(direction: Direction, skipEntryAnimation: boolean): string {
if (skipEntryAnimation) return "";
return direction === "forward"
? "animate-in fade-in-0 slide-in-from-right-2"
: "animate-in fade-in-0 slide-in-from-left-2";
}

export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
const onboardingPause = useOnboardingPause();
const [stepIndex, setStepIndex] = useState(onboardingPause.paused?.stepIndex ?? 0);

const { open: openSettings } = useSettings();
// Pause onboarding (not dismiss) so users can configure providers in full settings, then resume.
const openProvidersSettings = useCallback(
(provider?: string) => {
onboardingPause.pause({ stepIndex, reason: "providers-settings" });
openSettings("providers", provider ? { expandProvider: provider } : undefined);
},
[onboardingPause, openSettings, stepIndex]
);

const { config: providersConfig, loading: providersLoading } = useProvidersConfig();
const visibleProviders = useMemo(() => getProviderIdsForUi(providersConfig), [providersConfig]);
const { addProject, userProjects } = useProjectContext();

const projectAddFormRef = useRef<ProjectAddFormHandle | null>(null);
const [isProjectCreating, setIsProjectCreating] = useState(false);
const [projectFormHasError, setProjectFormHasError] = useState(false);

const [direction, setDirection] = useState<Direction>("forward");

const { api } = useAPI();
const liveApi = useLiveAPI();
const {
data: muxGatewayAccountStatus,
error: muxGatewayAccountError,
isLoading: muxGatewayAccountLoading,
refresh: refreshMuxGatewayAccountStatus,
} = useMuxGatewayAccountStatus();

const backendBaseUrl = getBrowserBackendBaseUrl();
const backendOrigin = useMemo(() => {
try {
return new URL(backendBaseUrl).origin;
} catch {
return window.location.origin;
}
}, [backendBaseUrl]);

const isDesktop = !!window.api;

const [muxGatewayLoginStatus, setMuxGatewayLoginStatus] = useState<MuxGatewayLoginStatus>("idle");
const [muxGatewayLoginError, setMuxGatewayLoginError] = useState<string | null>(null);

const muxGatewayLoginAttemptRef = useRef(0);
const [muxGatewayDesktopFlowId, setMuxGatewayDesktopFlowId] = useState<string | null>(null);
const [muxGatewayServerState, setMuxGatewayServerState] = useState<string | null>(null);

const routing = useRouting();

const disableMuxGatewayRoute = useCallback(() => {
const nextPriority = routing.routePriority.filter((route) => route !== "mux-gateway");
if (!nextPriority.includes("direct")) {
nextPriority.push("direct");
}

// Legacy configs can persist per-model mux-gateway overrides, and resolve.ts
// gives those overrides precedence over route priority order.
const nextOverrides = Object.fromEntries(
Object.entries(routing.routeOverrides).filter(([, route]) => route !== "mux-gateway")
);

if (
nextPriority.length === routing.routePriority.length &&
Object.keys(nextOverrides).length === Object.keys(routing.routeOverrides).length
) {
return;
}

routing.setRoutePreferences(nextPriority, nextOverrides);
}, [routing]);

const cancelMuxGatewayLogin = useCallback(() => {
muxGatewayLoginAttemptRef.current++;

if (isDesktop && api && muxGatewayDesktopFlowId) {
void api.muxGatewayOauth.cancelDesktopFlow({ flowId: muxGatewayDesktopFlowId });
}

setMuxGatewayDesktopFlowId(null);
setMuxGatewayServerState(null);
setMuxGatewayLoginStatus("idle");
setMuxGatewayLoginError(null);
}, [api, isDesktop, muxGatewayDesktopFlowId]);

const startMuxGatewayLogin = useCallback(async () => {
const attempt = ++muxGatewayLoginAttemptRef.current;

try {
setMuxGatewayLoginError(null);
setMuxGatewayDesktopFlowId(null);
setMuxGatewayServerState(null);

if (isDesktop) {
if (!liveApi) {
setMuxGatewayLoginStatus("error");
setMuxGatewayLoginError("Xum API not connected.");
return;
}

setMuxGatewayLoginStatus("starting");
const startResult = await liveApi.muxGatewayOauth.startDesktopFlow();

if (attempt !== muxGatewayLoginAttemptRef.current) {
if (startResult.success) {
void liveApi.muxGatewayOauth.cancelDesktopFlow({ flowId: startResult.data.flowId });
}
return;
}

if (!startResult.success) {
setMuxGatewayLoginStatus("error");
setMuxGatewayLoginError(startResult.error);
return;
}

const { flowId, authorizeUrl } = startResult.data;
setMuxGatewayDesktopFlowId(flowId);
setMuxGatewayLoginStatus("waiting");

// Desktop main process intercepts external window.open() calls and routes them via shell.openExternal.
window.open(authorizeUrl, "_blank", "noopener");

if (attempt !== muxGatewayLoginAttemptRef.current) {
return;
}

const waitResult = await liveApi.muxGatewayOauth.waitForDesktopFlow({ flowId });

if (attempt !== muxGatewayLoginAttemptRef.current) {
return;
}

if (waitResult.success) {
setMuxGatewayLoginStatus("success");

const refreshPromise = refreshMuxGatewayAccountStatus();
// Time-box the balance check so a stalled gateway endpoint doesn't
// block first-time onboarding defaults.
const accountStatus = await Promise.race([
refreshPromise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5_000)),
]);

if (attempt !== muxGatewayLoginAttemptRef.current) {
return;
}

const hasCredits = accountStatus == null || accountStatus.remaining_microdollars > 0;

if (!hasCredits) {
disableMuxGatewayRoute();
}

// If the timeout won the race, the balance check is still in flight.
// When it eventually resolves with depleted credits, disable gateway routing.
if (accountStatus == null) {
void refreshPromise.then((laterStatus) => {
if (muxGatewayLoginAttemptRef.current !== attempt) return;
if (laterStatus?.remaining_microdollars === 0) {
disableMuxGatewayRoute();
}
});
}
return;
}

setMuxGatewayLoginStatus("error");
setMuxGatewayLoginError(waitResult.error);
return;
}

// Browser/server mode: use unauthenticated bootstrap route.
// Open popup synchronously to preserve user gesture context (avoids popup blockers).
const popup = window.open("about:blank", "_blank");
if (!popup) {
throw new Error("Popup blocked - please allow popups and try again.");
}

setMuxGatewayLoginStatus("starting");

const startUrl = new URL(`${backendBaseUrl}/auth/mux-gateway/start`);
const authToken = getServerAuthToken();

let json: { authorizeUrl?: unknown; state?: unknown; error?: unknown };
try {
const res = await fetch(startUrl, {
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,
});

const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
const body = await res.text();
const prefix = body.trim().slice(0, 80);
throw new Error(
`Unexpected response from ${startUrl.toString()} (expected JSON, got ${
contentType || "unknown"
}): ${prefix}`
);
}

json = (await res.json()) as typeof json;

if (!res.ok) {
const message = typeof json.error === "string" ? json.error : `HTTP ${res.status}`;
throw new Error(message);
}
} catch (err) {
popup.close();
throw err;
}

if (attempt !== muxGatewayLoginAttemptRef.current) {
popup.close();
return;
}

if (typeof json.authorizeUrl !== "string" || typeof json.state !== "string") {
popup.close();
throw new Error(`Invalid response from ${startUrl.pathname}`);
}

setMuxGatewayServerState(json.state);
popup.location.href = json.authorizeUrl;
setMuxGatewayLoginStatus("waiting");
} catch (err) {
if (attempt !== muxGatewayLoginAttemptRef.current) {
return;
}

const message = getErrorMessage(err);
setMuxGatewayLoginStatus("error");
setMuxGatewayLoginError(message);
}
}, [liveApi, backendBaseUrl, disableMuxGatewayRoute, isDesktop, refreshMuxGatewayAccountStatus]);

useEffect(() => {
const attempt = muxGatewayLoginAttemptRef.current;

if (isDesktop || muxGatewayLoginStatus !== "waiting" || !muxGatewayServerState) {
return;
}

const handleMessage = (event: MessageEvent<OAuthMessage>) => {
if (event.origin !== backendOrigin) return;
if (muxGatewayLoginAttemptRef.current !== attempt) return;

const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type !== "mux-gateway-oauth") return;
if (data.state !== muxGatewayServerState) return;

if (data.ok === true) {
setMuxGatewayLoginStatus("success");

const refreshPromise = refreshMuxGatewayAccountStatus();
// Time-box the balance check so a stalled gateway endpoint doesn't
// block first-time onboarding defaults.
void Promise.race([
refreshPromise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5_000)),
]).then((accountStatus) => {
if (muxGatewayLoginAttemptRef.current !== attempt) return;

const hasCredits = accountStatus == null || accountStatus.remaining_microdollars > 0;

if (!hasCredits) {
disableMuxGatewayRoute();
}

// If the timeout won the race, the balance check is still in flight.
// When it eventually resolves with depleted credits, disable gateway routing.
if (accountStatus == null) {
void refreshPromise.then((laterStatus) => {
if (muxGatewayLoginAttemptRef.current !== attempt) return;
if (laterStatus?.remaining_microdollars === 0) {
disableMuxGatewayRoute();
}
});
}
});
return;
}

const msg = typeof data.error === "string" ? data.error : "Login failed";
setMuxGatewayLoginStatus("error");
setMuxGatewayLoginError(msg);
};

window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [
backendOrigin,
disableMuxGatewayRoute,
isDesktop,
muxGatewayLoginStatus,
muxGatewayServerState,
refreshMuxGatewayAccountStatus,
]);

const muxGatewayCouponCodeSet = providersConfig?.["mux-gateway"]?.couponCodeSet ?? false;
const muxGatewayLoginInProgress =
muxGatewayLoginStatus === "waiting" || muxGatewayLoginStatus === "starting";
const muxGatewayIsLoggedIn = muxGatewayCouponCodeSet || muxGatewayLoginStatus === "success";

const muxGatewayLoginButtonLabel =
muxGatewayLoginStatus === "error"
? "Try again"
: muxGatewayLoginInProgress
? "Waiting for login..."
: muxGatewayIsLoggedIn
? "Re-login to Xum Gateway"
: "Login with Xum Gateway";

const onboardingProviders = useMemo(() => {
const gatewayPriority: ProviderName[] = ["mux-gateway", "openrouter"];
const directProviders: string[] = [];
const localProviders: string[] = [];
const otherGateways: string[] = [];

for (const provider of visibleProviders) {
if (!isBuiltInProvider(provider)) {
directProviders.push(provider);
continue;
}

const providerDef = PROVIDER_DEFINITIONS[provider];
if (providerDef.kind === "direct") {
directProviders.push(provider);
} else if (providerDef.kind === "local") {
localProviders.push(provider);
} else if (!gatewayPriority.includes(provider)) {
otherGateways.push(provider);
}
}

const prioritizedGateways = gatewayPriority.filter((provider) =>
visibleProviders.includes(provider)
);

return [...prioritizedGateways, ...otherGateways, ...directProviders, ...localProviders];
}, [visibleProviders]);

const configuredProviders = useMemo(
() => visibleProviders.filter((provider) => providersConfig?.[provider]?.isConfigured === true),
[providersConfig, visibleProviders]
);

const configuredProvidersSummary = useMemo(() => {
if (configuredProviders.length === 0) {
return null;
}

return configuredProviders
.map((provider) => formatProviderDisplayName(provider, providersConfig?.[provider]))
.join(", ");
}, [configuredProviders, providersConfig]);

const [hasConfiguredProvidersAtStart, setHasConfiguredProvidersAtStart] = useState<
boolean | null
>(null);

useEffect(() => {
if (hasConfiguredProvidersAtStart !== null) {
return;
}

if (providersLoading) {
return;
}

setHasConfiguredProvidersAtStart(configuredProviders.length > 0);
}, [configuredProviders.length, hasConfiguredProvidersAtStart, providersLoading]);

const commandPaletteShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE);
const commandPaletteActionsShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE_ACTIONS);
const agentPickerShortcut = formatKeybind(KEYBINDS.TOGGLE_AGENT);
const cycleAgentShortcut = formatKeybind(KEYBINDS.CYCLE_AGENT);

const steps = useMemo((): WizardStep[] => {
if (hasConfiguredProvidersAtStart === null) {
return [
{
key: "loading",
title: "Getting started",
icon: <Sparkles className="h-4 w-4" />,
body: (
<>
<p>Checking your provider configuration…</p>
</>
),
},
];
}

const nextSteps: WizardStep[] = [];

if (hasConfiguredProvidersAtStart === false) {
nextSteps.push({
key: "mux-gateway",
title: "Xum Gateway (evaluation credits)",
icon: <Sparkles className="h-4 w-4" />,
body: (
<>
<p>
Xum Gateway enables you to use free AI tokens from{" "}
<a
href="https://coder.com"
target="_blank"
rel="noopener noreferrer"
className="text-accent underline"
>
Coder
</a>
.
</p>

<p>
OSS contributors with GitHub accounts older than 12 months (or GitHub Pro members) can
use this to get free evaluation credits.
</p>

{muxGatewayIsLoggedIn ? (
<div className="mt-3 space-y-2">
<div className="border-border-medium bg-background-secondary rounded-md border p-2 text-xs">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-foreground font-medium">Xum Gateway account</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
void refreshMuxGatewayAccountStatus();
}}
disabled={muxGatewayAccountLoading}
>
{muxGatewayAccountLoading ? "Refreshing..." : "Refresh"}
</Button>
</div>

<div className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-4">
<span className="text-content-secondary">Balance</span>
<span className="text-foreground font-mono">
{formatMuxGatewayBalance(muxGatewayAccountStatus?.remaining_microdollars)}
</span>
</div>

<div className="flex items-center justify-between gap-4">
<span className="text-content-secondary">Concurrent requests per user</span>
<span className="text-foreground font-mono">
{muxGatewayAccountStatus?.ai_gateway_concurrent_requests_per_user ?? "—"}
</span>
</div>
</div>

{muxGatewayAccountError && (
<div className="text-destructive mt-2">{muxGatewayAccountError}</div>
)}
</div>

{muxGatewayAccountStatus?.remaining_microdollars === 0 && (
<div className="border-destructive/20 bg-destructive/5 mt-2 rounded-md border p-2 text-xs">
<p className="text-destructive font-medium">
Your Xum Gateway credits are depleted.
</p>
<p className="text-content-secondary mt-1">
Gateway routing has been disabled. Configure another provider below, or visit{" "}
<a
href={MUX_GATEWAY_ORIGIN}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline"
>
{new URL(MUX_GATEWAY_ORIGIN).host}
</a>{" "}
to add credits.
</p>
</div>
)}
</div>
) : (
<>
<div className="mt-3 flex flex-wrap gap-2">
<Button
onClick={() => {
void startMuxGatewayLogin();
}}
disabled={muxGatewayLoginInProgress}
>
{muxGatewayLoginButtonLabel}
</Button>

{muxGatewayLoginInProgress && (
<Button variant="secondary" onClick={cancelMuxGatewayLogin}>
Cancel
</Button>
)}
</div>

{muxGatewayLoginStatus === "waiting" && (
<p className="mt-3">Finish the login flow in your browser, then return here.</p>
)}

{muxGatewayLoginStatus === "error" && muxGatewayLoginError && (
<p className="mt-3">
<strong className="text-destructive">Login failed:</strong>{" "}
{muxGatewayLoginError}
</p>
)}
</>
)}

<p className="mt-3">You can also receive those credits through:</p>

<ul className="ml-4 list-disc space-y-1">
<li>
early adopters can request credits tied to their GH logins on our{" "}
<a
href="https://discord.gg/VfZXvtnR"
target="_blank"
rel="noopener noreferrer"
className="text-accent underline"
>
Discord
</a>
</li>
<li>
vouchers which you can{" "}
<a
href={MUX_GATEWAY_ORIGIN}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline"
>
claim here
</a>
</li>
</ul>

<p className="mt-3">
You can enable this in{" "}
<button
type="button"
className="text-accent underline"
onClick={() => openProvidersSettings()}
>
Settings → Providers
</button>
.
</p>
</>
),
});
}

nextSteps.push({
key: "providers",
title: "Choose your own AI providers",
icon: <Sparkles className="h-4 w-4" />,
body: (
<>
<p>
Xum is provider-agnostic: bring your own keys, mix and match models, or run locally.
</p>

{configuredProviders.length > 0 && configuredProvidersSummary ? (
<p className="mt-3 text-xs">
<span className="text-foreground font-medium">Configured:</span>{" "}
{configuredProvidersSummary}
</p>
) : (
<p className="mt-3 text-xs">No providers configured yet.</p>
)}

<div className="mt-3">
<div className="text-foreground mb-2 text-xs font-medium">Available providers</div>
<div className="grid grid-cols-2 gap-2">
{onboardingProviders.map((provider) => {
const configured = providersConfig?.[provider]?.isConfigured === true;

return (
<button
key={provider}
type="button"
className="bg-background-secondary border-border-medium text-foreground hover:bg-hover flex w-full cursor-pointer items-center justify-between rounded-md border px-2 py-1 text-left text-xs"
title={configured ? "Configured" : "Not configured"}
onClick={() => openProvidersSettings(provider)}
>
<ProviderWithIcon provider={provider} displayName iconClassName="text-accent" />
<span
className={`h-2 w-2 rounded-full ${
configured ? "bg-green-500" : "bg-border-medium"
}`}
/>
</button>
);
})}
</div>

<div className="text-content-secondary mt-2 flex items-center gap-2 text-xs">
<span className="h-2 w-2 rounded-full bg-green-500" />
<span>Configured</span>
<span className="bg-border-medium h-2 w-2 rounded-full" />
<span>Not configured</span>
</div>
</div>

<p className="mt-3">
Configure keys and endpoints in{" "}
<button
type="button"
className="text-accent underline"
onClick={() => openProvidersSettings()}
>
Settings → Providers
</button>
.
</p>
</>
),
});

const projectStepIndex = nextSteps.length;

nextSteps.push({
key: "projects",
title: "Add your first project",
icon: <Briefcase className="h-4 w-4" />,
body: (
<>
<p>
Projects are the folders or repos you want Xum to work in. Add a local folder, clone
from GitHub, or create a new project, then click Next.
</p>

{userProjects.size > 0 ? (
<p className="mt-3 text-xs">
<span className="text-foreground font-medium">Configured:</span> {userProjects.size}{" "}
project
{userProjects.size === 1 ? "" : "s"}
</p>
) : (
<p className="mt-3 text-xs">No projects added yet.</p>
)}

<div className="mt-3">
<ProjectAddForm
ref={projectAddFormRef}
isOpen
autoFocus={userProjects.size === 0}
hideFooter
onIsCreatingChange={setIsProjectCreating}
onErrorChange={setProjectFormHasError}
onSuccess={(normalizedPath, projectConfig) => {
addProject(normalizedPath, projectConfig);
updatePersistedState(getAgentsInitNudgeKey(normalizedPath), true);
setDirection("forward");
setStepIndex(projectStepIndex + 1);
}}
/>
</div>

{/* Hide the "click Next" nudge while the form shows an error; the two contradict. */}
{!projectFormHasError && (
<p className="mt-2 text-xs">
{userProjects.size > 0
? "Add another folder or repo, or leave this blank and click Next to continue."
: "Click Next to add this project."}
</p>
)}
</>
),
});

nextSteps.push({
key: "agents",
title: "Agents: Plan, Exec, and custom",
icon: <Bot className="h-4 w-4" />,
body: (
<>
<p>
Agents are file-based definitions (system prompt + tool policy). You can create
project-local agents in <code className="text-accent">.xum/agents/*.md</code> or global
agents in <code className="text-accent">~/.xum/agents/*.md</code>.
</p>

<div className="mt-3 grid gap-2">
<Card icon={<Sparkles className="h-4 w-4" />} title="Use Plan to design the spec">
When the change is complex, switch to a plan-like agent first: write an explicit plan
(files, steps, risks), then execute.
</Card>

<Card icon={<Bot className="h-4 w-4" />} title="Quick shortcuts">
<div className="mt-1 flex flex-wrap items-center gap-2">
<span>Agent picker</span>
<kbd className={KBD_CLASSNAME}>{agentPickerShortcut}</kbd>
<span className="text-content-secondary mx-1">•</span>
<span>Cycle agent</span>
<kbd className={KBD_CLASSNAME}>{cycleAgentShortcut}</kbd>
</div>
</Card>
</div>

<div className="mt-3 flex items-center gap-3">
<DocsLink path="/agents" className={WIZARD_DOCS_LINK_CLASSNAME}>
Agent docs
</DocsLink>
<DocsLink path="/agents/plan-mode" className={WIZARD_DOCS_LINK_CLASSNAME}>
Plan mode
</DocsLink>
</div>
</>
),
});

nextSteps.push({
key: "runtimes",
title: "Multiple runtimes",
icon: <Boxes className="h-4 w-4" />,
body: (
<>
<p>
Each workspace can run in the environment that fits the job: keep it local, isolate with
a git worktree, run remotely over SSH, or use a per-workspace Docker container.
</p>

<div className="mt-3 grid gap-2">
<Card icon={<LocalIcon size={14} />} title="Local">
Work directly in your project directory.
</Card>
<Card icon={<WorktreeIcon size={14} />} title="Worktree">
Isolated git worktree under <code className="text-accent">~/.xum/src</code>.
</Card>
<Card icon={<SSHIcon size={14} />} title="SSH">
Remote clone and commands run on an SSH host.
</Card>
<Card icon={<CoderIcon size={14} />} title="Coder (SSH)">
Use Coder workspaces over SSH for a managed remote dev environment.
</Card>
<Card icon={<DockerIcon size={14} />} title="Docker">
Isolated container per workspace.
</Card>
</div>

<p className="mt-3">You can set a project default runtime in the workspace controls.</p>
</>
),
});

nextSteps.push({
key: "mcp",
title: "MCP servers",
icon: <Server className="h-4 w-4" />,
body: (
<>
<p>
MCP servers extend Xum with tools (memory, ticketing, databases, internal APIs).
Configure them globally, with optional repo overrides and per-workspace overrides.
</p>

<div className="mt-3 grid gap-2">
<Card icon={<Server className="h-4 w-4" />} title="Global config">
<code className="text-accent">~/.xum/mcp.jsonc</code>
</Card>
<Card icon={<Server className="h-4 w-4" />} title="Repo overrides">
<code className="text-accent">./.xum/mcp.jsonc</code>
</Card>
<Card icon={<Server className="h-4 w-4" />} title="Workspace overrides">
<code className="text-accent">.xum/mcp.local.jsonc</code>
</Card>
</div>

<p className="mt-3">
Manage servers in <span className="text-foreground">Settings → MCP</span>.
</p>
</>
),
});

nextSteps.push({
key: "palette",
title: "Command palette",
icon: <CommandIcon className="h-4 w-4" />,
body: (
<>
<p>
The command palette is the fastest way to navigate, create workspaces, and discover
features.
</p>

<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-content-secondary text-sm">Open command palette</span>
<kbd className={KBD_CLASSNAME}>{commandPaletteShortcut}</kbd>
</div>

<div className="mt-3">
<CommandPalettePreview shortcut={commandPaletteShortcut} />
</div>

<p className="mt-3">
Tip: type <code className="text-accent">&gt;</code> for commands, press{" "}
<kbd className={KBD_CLASSNAME}>{commandPaletteActionsShortcut}</kbd> to open command
mode directly, and use <code className="text-accent">/</code> for slash commands.
</p>
</>
),
});

return nextSteps;
}, [
addProject,
agentPickerShortcut,
cancelMuxGatewayLogin,
commandPaletteActionsShortcut,
commandPaletteShortcut,
configuredProviders.length,
configuredProvidersSummary,
cycleAgentShortcut,
hasConfiguredProvidersAtStart,
muxGatewayAccountError,
muxGatewayAccountLoading,
muxGatewayAccountStatus,
muxGatewayIsLoggedIn,
muxGatewayLoginButtonLabel,
muxGatewayLoginError,
muxGatewayLoginInProgress,
muxGatewayLoginStatus,
openProvidersSettings,
projectFormHasError,
userProjects.size,
providersConfig,
refreshMuxGatewayAccountStatus,
startMuxGatewayLogin,
onboardingProviders,
]);

// Clamp stepIndex to valid range when the step list changes. Skip while still
// loading provider config — the temporary single-item "loading" array would
// reset a restored stepIndex (e.g. after resuming from paused onboarding).
const isLoading = hasConfiguredProvidersAtStart === null;
useEffect(() => {
if (isLoading) return;
setStepIndex((index) => Math.min(index, steps.length - 1));
}, [isLoading, steps.length]);

const totalSteps = steps.length;
const currentStep = steps[stepIndex] ?? steps[0];
const firstRunView = useFirstRunViewHandoff(isLoading);

useEffect(() => {
if (currentStep?.key !== "mux-gateway" && muxGatewayLoginInProgress) {
cancelMuxGatewayLogin();
}
}, [cancelMuxGatewayLogin, currentStep?.key, muxGatewayLoginInProgress]);

if (!currentStep) {
if (!currentStep || firstRunView.hideWhileLoading) {
return null;
}

const canGoBack = !isLoading && stepIndex > 0;
const canGoForward = !isLoading && stepIndex < totalSteps - 1;

const goBack = () => {
if (!canGoBack) {
return;
}
setDirection("back");
firstRunView.stopSkipping();
// Changing steps remounts the project form without its inline error, so
// clear the stale flag or the "click Next" nudge stays hidden on return.
setProjectFormHasError(false);
setStepIndex((i) => Math.max(0, i - 1));
};

const goForward = () => {
if (!canGoForward) {
return;
}
setDirection("forward");
firstRunView.stopSkipping();
Comment thread
ThomasK33 marked this conversation as resolved.
setProjectFormHasError(false);
setStepIndex((i) => Math.min(totalSteps - 1, i + 1));
};

const isProjectStep = currentStep.key === "projects";

const primaryLabel = isLoading ? "Next" : canGoForward ? "Next" : "Done";
const primaryButtonLabel = isProjectStep && isProjectCreating ? "Adding..." : primaryLabel;
const primaryDisabled = isLoading || (isProjectStep && isProjectCreating);

return (
<SplashScreen
title={currentStep.title}
onDismiss={() => {
cancelMuxGatewayLogin();
props.onDismiss();
}}
dismissLabel={null}
skipEntryAnimation={firstRunView.skipDialogEntry}
footerClassName="justify-between"
footer={
<>
<div>
{canGoBack && (
<Button variant="secondary" onClick={goBack} className="min-w-24">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
)}
</div>

<div className="flex items-center gap-3">
<Button
className="min-w-24"
onClick={() => {
if (primaryDisabled) {
return;
}

if (isProjectStep) {
const form = projectAddFormRef.current;
if (!form) {
goForward();
return;
}

const trimmedInput = form.getTrimmedInput();
if (!trimmedInput && userProjects.size > 0) {
goForward();
return;
}

void form.submit();
return;
}

if (canGoForward) {
goForward();
return;
}

props.onDismiss();
}}
disabled={primaryDisabled}
>
{primaryButtonLabel}
</Button>

<Button variant="secondary" onClick={props.onDismiss} className="min-w-24">
Skip
</Button>
</div>
</>
}
>
<div className="text-content-secondary flex flex-col gap-4">
<WizardHeader stepIndex={stepIndex} totalSteps={totalSteps} />

<div
key={currentStep.key}
className={`flex flex-col gap-3 ${
direction === "forward"
? "animate-in fade-in-0 slide-in-from-right-2"
: "animate-in fade-in-0 slide-in-from-left-2"
}`}
data-onboarding-step={currentStep.key}
className={`flex flex-col gap-3 ${stepEntryClassName(direction, firstRunView.skipStepEntry)}`}
>
<div className="text-foreground flex items-center gap-2 text-sm font-medium">
<span className="bg-accent/10 text-accent inline-flex h-8 w-8 items-center justify-center rounded-md">
{currentStep.icon}
</span>
<span>{currentStep.title}</span>
</div>

<div className="text-content-secondary flex flex-col gap-3 text-sm">
{currentStep.body}
</div>
</div>
</div>
</SplashScreen>
);
}

Check warning on line 1205 in src/browser/features/SplashScreens/OnboardingWizardSplash.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/features/SplashScreens/OnboardingWizardSplash.tsx:225-1205 OnboardingWizardSplash: touched hotspot, CC 125 unchanged: consider shrinking it
3 changes: 3 additions & 0 deletions src/browser/features/SplashScreens/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface SplashScreenProps {
footer?: React.ReactNode;
/** Optional class name for the DialogFooter container. */
footerClassName?: string;
/** Open without the dialog's entry animation (the static first-run view shows this frame). */
skipEntryAnimation?: boolean;
}

export function SplashScreen(props: SplashScreenProps) {
Expand All @@ -47,6 +49,7 @@ export function SplashScreen(props: SplashScreenProps) {
<DialogContent
maxWidth="500px"
aria-describedby={undefined}
skipEntryAnimation={props.skipEntryAnimation}
className="max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto]"
onInteractOutside={(e) => e.preventDefault()}
>
Expand Down
5 changes: 5 additions & 0 deletions src/browser/features/SplashScreens/SplashScreenProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView";
import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage";

/** Whether the server's static first-run view is on screen (T3 #5971, C step 2). */
export function hasFirstRunView(): boolean {
return document.getElementById(FIRST_RUN_VIEW_ID) != null;
}

/** Removes the static first-run view. Callers do it whenever the live wizard will not replace it. */
export function removeFirstRunView(): void {
document.getElementById(FIRST_RUN_VIEW_ID)?.remove();
Expand Down
155 changes: 155 additions & 0 deletions tests/ui/onboarding/wizardHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* The onboarding wizard and the static first-run view (T3 #5971, C step 2, plan section 5.6).
* The server will put a static copy of the wizard's gateway step in #xum-pre before the app JS runs.
* The live wizard takes over in one commit. Without #xum-pre, the wizard must render exactly as before.
*/
import "../dom";
import React from "react";
import { act, cleanup, fireEvent, render } from "@testing-library/react";

let mockProviders: { config: Record<string, unknown> | null; loading: boolean };

jest.mock("@/browser/hooks/useProvidersConfig", () => ({
useProvidersConfig: () => ({
...mockProviders,
refresh: jest.fn(),
updateOptimistically: jest.fn(),
}),
}));
jest.mock("@/browser/features/SplashScreens/SplashScreenProvider", () => ({
...jest.requireActual<typeof import("@/browser/features/SplashScreens/SplashScreenProvider")>(
"@/browser/features/SplashScreens/SplashScreenProvider"
),
useOnboardingPause: () => ({ paused: null, pause: jest.fn(), resume: jest.fn() }),
}));
jest.mock("@/browser/contexts/SettingsContext", () => ({
useSettings: () => ({ open: jest.fn(), registerOnClose: () => () => undefined }),
}));
jest.mock("@/browser/contexts/ProjectContext", () => ({
useProjectContext: () => ({ addProject: jest.fn(), userProjects: new Map() }),
}));
// useLiveAPI is main's live-client hook (#6323): with no API it is null, like useAPI's api.
jest.mock("@/browser/contexts/API", () => ({
useAPI: () => ({ api: null }),
useLiveAPI: () => null,
}));
jest.mock("@/browser/hooks/useRouting", () => ({
useRouting: () => ({ navigateToProject: jest.fn() }),
}));
jest.mock("@/browser/hooks/useMuxGatewayAccountStatus", () => ({
...jest.requireActual<typeof import("@/browser/hooks/useMuxGatewayAccountStatus")>(
"@/browser/hooks/useMuxGatewayAccountStatus"
),
useMuxGatewayAccountStatus: () => ({
data: null,
error: null,
isLoading: false,
refresh: jest.fn(),
}),
}));

import { OnboardingWizardSplash } from "@/browser/features/SplashScreens/OnboardingWizardSplash";

// Steps are found by their key (data-onboarding-step), so rewording the copy does not break these tests.
const LOADING_STEP = "loading";
const GATEWAY_STEP = "mux-gateway";

const dialog = () => document.body.querySelector<HTMLElement>("[role=dialog]");
const overlay = () => document.body.querySelector<HTMLElement>("[data-modal-dialog-overlay]");
const step = () => document.body.querySelector<HTMLElement>("[data-onboarding-step]");
const shows = (key: string) => step()?.dataset.onboardingStep === key;
const animatesIn = () =>
step()?.className.includes("animate-in fade-in-0 slide-in-from-right-2") === true;

function renderWizard() {
return render(React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() }));
}

afterEach(() => {
cleanup();
document.body.innerHTML = "";
});

describe("the onboarding wizard without a static first-run view", () => {
test("shows its loading step, then the gateway step, with the dialog's entry animation", () => {
mockProviders = { config: null, loading: true };
const view = renderWizard();
expect(shows(LOADING_STEP)).toBe(true);
expect(dialog()?.className).toContain("data-[state=open]:zoom-in-95");
expect(overlay()?.className).toContain("data-[state=open]:fade-in-0");
expect(dialog()?.style.animation).toBe("");
expect(overlay()?.style.animation).toBe("");

mockProviders = { config: {}, loading: false };
act(() => view.rerender(React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() })));
expect(shows(LOADING_STEP)).toBe(false);
expect(shows(GATEWAY_STEP)).toBe(true);
expect(animatesIn()).toBe(true);
expect(dialog()?.style.animation).toBe("");
});

test("animates each step it moves to", () => {
mockProviders = { config: {}, loading: false };
renderWizard();
fireEvent.click(
Array.from(dialog()!.querySelectorAll("button")).find((b) => b.textContent === "Next")!
);
expect(shows(GATEWAY_STEP)).toBe(false);
expect(animatesIn()).toBe(true);
});
});

describe("the onboarding wizard under the static first-run view (#xum-pre)", () => {
const firstRunView = () => document.getElementById("xum-pre");
const clickNext = () =>
fireEvent.click(
Array.from(dialog()!.querySelectorAll("button")).find((b) => b.textContent === "Next")!
);

beforeEach(() => {
const view = document.createElement("div");
view.id = "xum-pre";
document.body.append(view);
});

test("never paints its loading step, and no commit shows the gateway step and the static view together", () => {
// A Profiler's onRender runs in each commit of the wizard, after its layout effects and
// before passive effects: the point where the browser would paint.
const viewAtGatewayCommit: boolean[] = [];
const onRender = () => {
if (shows(GATEWAY_STEP)) viewAtGatewayCommit.push(firstRunView() != null);
};
const tree = () =>
React.createElement(
React.Profiler,
{ id: "wizard", onRender },
React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() })
);
mockProviders = { config: null, loading: true };
const view = render(tree());
expect(dialog()).toBeNull();
expect(firstRunView()).not.toBeNull();

mockProviders = { config: {}, loading: false };
act(() => view.rerender(tree()));
expect(viewAtGatewayCommit.length).toBeGreaterThan(0);
expect(viewAtGatewayCommit).not.toContain(true);
expect(firstRunView()).toBeNull();
// The static view showed this frame, so nothing animates in, until the user moves on.
expect(dialog()?.style.animation).toBe("none");
expect(overlay()?.style.animation).toBe("none");
expect(animatesIn()).toBe(false);
clickNext();
expect(animatesIn()).toBe(true);
// The dialog and overlay stay open, so dropping their override would replay their entry.
expect(dialog()?.style.animation).toBe("none");
expect(overlay()?.style.animation).toBe("none");
});

test("removes the static view when it opens past the gateway step", () => {
mockProviders = { config: { anthropic: { isConfigured: true } }, loading: false };
renderWizard();
expect(shows(GATEWAY_STEP)).toBe(false);
expect(firstRunView()).toBeNull();
});
});
Loading