) => {
+ 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) {
+ if (muxGatewayApplyDefaultModelsOnSuccessRef.current) {
+ muxGatewayApplyDefaultModelsOnSuccessRef.current = false;
+
+ const applyLatest = (latestConfig: ProvidersConfigMap | null) => {
+ if (muxGatewayLoginAttemptRef.current !== attempt) return;
+ updatePersistedState(GATEWAY_MODELS_KEY, getEligibleGatewayModels(latestConfig));
+ };
+
+ if (api) {
+ api.providers
+ .getConfig()
+ .then(applyLatest)
+ .catch(() => applyLatest(providersConfig));
+ } else {
+ applyLatest(providersConfig);
+ }
+ }
+
+ setMuxGatewayLoginStatus("success");
+ return;
+ }
+
+ const msg = typeof data.error === "string" ? data.error : "Login failed";
+ setMuxGatewayLoginStatus("error");
+ setMuxGatewayLoginError(msg);
+ };
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [
+ api,
+ backendOrigin,
+ isDesktop,
+ muxGatewayLoginStatus,
+ muxGatewayServerState,
+ providersConfig,
+ ]);
+
+ 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 Mux Gateway"
+ : "Login with Mux Gateway";
+
+ const configuredProviders = useMemo(
+ () =>
+ SUPPORTED_PROVIDERS.filter((provider) => providersConfig?.[provider]?.isConfigured === true),
+ [providersConfig]
+ );
+
+ const configuredProvidersSummary = useMemo(() => {
+ if (configuredProviders.length === 0) {
+ return null;
+ }
+
+ return configuredProviders.map((p) => PROVIDER_DISPLAY_NAMES[p]).join(", ");
+ }, [configuredProviders]);
+
+ 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 agentPickerShortcut = formatKeybind(KEYBINDS.TOGGLE_MODE);
+ const cycleAgentShortcut = formatKeybind(KEYBINDS.CYCLE_AGENT);
+
+ const steps = useMemo((): WizardStep[] => {
+ if (hasConfiguredProvidersAtStart === null) {
+ return [
+ {
+ key: "loading",
+ title: "Getting started",
+ icon: ,
+ body: (
+ <>
+ Checking your provider configuration…
+ >
+ ),
+ },
+ ];
+ }
+
+ const nextSteps: WizardStep[] = [];
+
+ if (hasConfiguredProvidersAtStart === false) {
+ nextSteps.push({
+ key: "mux-gateway",
+ title: "Mux Gateway (evaluation credits)",
+ icon: ,
+ body: (
+ <>
+
+ Mux Gateway enables you to use free AI tokens from{" "}
+
+ Coder
+
+ .
+
+
+
+ OSS contributors with GitHub accounts older than 12 months (or GitHub Pro members) can
+ use this to get free evaluation credits.
+
+
+
+ {
+ void startMuxGatewayLogin();
+ }}
+ disabled={muxGatewayLoginInProgress}
+ >
+ {muxGatewayLoginButtonLabel}
+
+
+ {muxGatewayLoginInProgress && (
+
+ Cancel
+
+ )}
+
+
+ {muxGatewayLoginStatus === "success" && Login successful.
}
+
+ {muxGatewayLoginStatus === "waiting" && (
+ Finish the login flow in your browser, then return here.
+ )}
+
+ {muxGatewayLoginStatus === "error" && muxGatewayLoginError && (
+
+ Login failed: {muxGatewayLoginError}
+
+ )}
+
+ You can also receive those credits through:
+
+
+
+ early adopters can request credits tied to their GH logins on our{" "}
+
+ Discord
+
+
+
+ vouchers which you can{" "}
+
+ claim here
+
+
+
+
+
+ You can enable this in{" "}
+ openSettings("providers")}
+ >
+ Settings → Providers
+
+ .
+
+ >
+ ),
+ });
+ }
+
+ nextSteps.push({
+ key: "providers",
+ title: "Choose your own AI providers",
+ icon: ,
+ body: (
+ <>
+
+ Mux is provider-agnostic: bring your own keys, mix and match models, or run locally.
+
+
+ {configuredProviders.length > 0 && configuredProvidersSummary ? (
+
+ Configured: {" "}
+ {configuredProvidersSummary}
+
+ ) : (
+ No providers configured yet.
+ )}
+
+
+
Available providers
+
+ {SUPPORTED_PROVIDERS.map((provider) => {
+ const configured = providersConfig?.[provider]?.isConfigured === true;
+
+ return (
+
openSettings("providers", { expandProvider: provider })}
+ >
+
+
+
+ );
+ })}
+
+
+
+
+ Configured
+
+ Not configured
+
+
+
+
+ Configure keys and endpoints in{" "}
+ openSettings("providers")}
+ >
+ Settings → Providers
+
+ .
+
+ >
+ ),
+ });
+
+ nextSteps.push({
+ key: "agents",
+ title: "Agents: Plan, Exec, and custom",
+ icon: ,
+ body: (
+ <>
+
+ Agents are file-based definitions (system prompt + tool policy). You can create
+ project-local agents in .mux/agents/*.md or global
+ agents in ~/.mux/agents/*.md.
+
+
+
+
} 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.
+
+
+
} title="Quick shortcuts">
+
+ Agent picker
+ {agentPickerShortcut}
+ •
+ Cycle agent
+ {cycleAgentShortcut}
+
+
+
+
+
+ Agent docs
+ Plan mode
+
+ >
+ ),
+ });
+
+ nextSteps.push({
+ key: "runtimes",
+ title: "Multiple runtimes",
+ icon: ,
+ body: (
+ <>
+
+ 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.
+
+
+
+ } title="Local">
+ Work directly in your project directory.
+
+ } title="Worktree">
+ Isolated git worktree under ~/.mux/src.
+
+ } title="SSH">
+ Remote clone and commands run on an SSH host.
+
+ } title="Coder (SSH)">
+ Use Coder workspaces over SSH for a managed remote dev environment.
+
+ } title="Docker">
+ Isolated container per workspace.
+
+
+
+ You can set a project default runtime in the workspace controls.
+ >
+ ),
+ });
+
+ nextSteps.push({
+ key: "mcp",
+ title: "MCP servers",
+ icon: ,
+ body: (
+ <>
+
+ MCP servers extend Mux with tools (memory, ticketing, databases, internal APIs).
+ Configure them per project and optionally override per workspace.
+
+
+
+ } title="Project config">
+ .mux/mcp.jsonc
+
+ } title="Workspace overrides">
+ .mux/mcp.local.jsonc
+
+
+
+
+ Manage servers in Settings → Projects or via{" "}
+ /mcp.
+
+ >
+ ),
+ });
+
+ nextSteps.push({
+ key: "palette",
+ title: "Command palette",
+ icon: ,
+ body: (
+ <>
+
+ The command palette is the fastest way to navigate, create workspaces, and discover
+ features.
+
+
+
+ Open command palette
+ {commandPaletteShortcut}
+
+
+
+
+
+
+
+ Tip: type > for commands and{" "}
+ / for slash commands.
+
+ >
+ ),
+ });
+
+ return nextSteps;
+ }, [
+ agentPickerShortcut,
+ cancelMuxGatewayLogin,
+ commandPaletteShortcut,
+ configuredProviders.length,
+ configuredProvidersSummary,
+ cycleAgentShortcut,
+ hasConfiguredProvidersAtStart,
+ muxGatewayLoginButtonLabel,
+ muxGatewayLoginError,
+ muxGatewayLoginInProgress,
+ muxGatewayLoginStatus,
+ openSettings,
+ providersConfig,
+ startMuxGatewayLogin,
+ ]);
+
+ useEffect(() => {
+ setStepIndex((index) => Math.min(index, steps.length - 1));
+ }, [steps.length]);
+
+ const totalSteps = steps.length;
+ const currentStep = steps[stepIndex] ?? steps[0];
+
+ useEffect(() => {
+ if (currentStep?.key !== "mux-gateway" && muxGatewayLoginInProgress) {
+ cancelMuxGatewayLogin();
+ }
+ }, [cancelMuxGatewayLogin, currentStep?.key, muxGatewayLoginInProgress]);
+
+ if (!currentStep) {
+ return null;
+ }
+
+ const isLoading = hasConfiguredProvidersAtStart === null;
+ const canGoBack = !isLoading && stepIndex > 0;
+ const canGoForward = !isLoading && stepIndex < totalSteps - 1;
+
+ const goBack = () => {
+ if (!canGoBack) {
+ return;
+ }
+ setDirection("back");
+ setStepIndex((i) => Math.max(0, i - 1));
+ };
+
+ const goForward = () => {
+ if (!canGoForward) {
+ return;
+ }
+ setDirection("forward");
+ setStepIndex((i) => Math.min(totalSteps - 1, i + 1));
+ };
+
+ const primaryLabel = isLoading ? "Next" : canGoForward ? "Next" : "Done";
+
+ return (
+ {
+ cancelMuxGatewayLogin();
+ props.onDismiss();
+ }}
+ dismissLabel={null}
+ footerClassName="justify-between"
+ footer={
+ <>
+
+ {canGoBack && (
+
+
+ Back
+
+ )}
+
+
+
+ {
+ if (isLoading) {
+ return;
+ }
+
+ if (canGoForward) {
+ goForward();
+ return;
+ }
+
+ props.onDismiss();
+ }}
+ disabled={isLoading}
+ >
+ {primaryLabel}
+
+
+
+ Skip
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {currentStep.icon}
+
+ {currentStep.title}
+
+
+
{currentStep.body}
+
+
+
+ );
+}
diff --git a/src/browser/components/splashScreens/SplashScreen.tsx b/src/browser/components/splashScreens/SplashScreen.tsx
index 71c42fa899..7782d9d82d 100644
--- a/src/browser/components/splashScreens/SplashScreen.tsx
+++ b/src/browser/components/splashScreens/SplashScreen.tsx
@@ -21,6 +21,10 @@ interface SplashScreenProps {
dismissOnPrimaryAction?: boolean;
/** Defaults to "Got it". Set to null to hide the dismiss button entirely. */
dismissLabel?: string | null;
+ /** Optional custom footer content (replaces primary + dismiss buttons). */
+ footer?: React.ReactNode;
+ /** Optional class name for the DialogFooter container. */
+ footerClassName?: string;
}
export function SplashScreen(props: SplashScreenProps) {
@@ -43,16 +47,23 @@ export function SplashScreen(props: SplashScreenProps) {
{props.title}
{props.children}
-
- {props.primaryAction && (
-
- {props.primaryAction.label}
-
- )}
- {props.dismissLabel !== null && (
-
- {props.dismissLabel ?? "Got it"}
-
+
+ {props.footer ?? (
+ <>
+ {props.primaryAction && (
+
+ {props.primaryAction.label}
+
+ )}
+ {props.dismissLabel !== null && (
+
+ {props.dismissLabel ?? "Got it"}
+
+ )}
+ >
)}
diff --git a/src/browser/components/splashScreens/index.ts b/src/browser/components/splashScreens/index.ts
index 1c34f6c226..0a3f8b9060 100644
--- a/src/browser/components/splashScreens/index.ts
+++ b/src/browser/components/splashScreens/index.ts
@@ -1,4 +1,4 @@
-import { LoginWithMuxGatewaySplash } from "./LoginWithMuxGateway";
+import { OnboardingWizardSplash } from "./OnboardingWizardSplash";
export interface SplashConfig {
id: string;
@@ -12,7 +12,7 @@ export interface SplashConfig {
// Priority 2 = Medium priority
// Priority 3+ = Higher priority (shown first)
export const SPLASH_REGISTRY: SplashConfig[] = [
- { id: "mux-gateway-login", priority: 4, component: LoginWithMuxGatewaySplash },
+ { id: "onboarding-wizard-v1", priority: 5, component: OnboardingWizardSplash },
// Future: { id: "new-feature-xyz", priority: 2, component: NewFeatureSplash },
];
diff --git a/src/browser/contexts/SettingsContext.tsx b/src/browser/contexts/SettingsContext.tsx
index 4041520c97..d0fd2359ae 100644
--- a/src/browser/contexts/SettingsContext.tsx
+++ b/src/browser/contexts/SettingsContext.tsx
@@ -7,12 +7,21 @@ import React, {
type ReactNode,
} from "react";
+interface OpenSettingsOptions {
+ /** When opening the Providers settings, expand the given provider. */
+ expandProvider?: string;
+}
+
interface SettingsContextValue {
isOpen: boolean;
activeSection: string;
- open: (section?: string) => void;
+ open: (section?: string, options?: OpenSettingsOptions) => void;
close: () => void;
setActiveSection: (section: string) => void;
+
+ /** One-shot hint for ProvidersSection to expand a provider. */
+ providersExpandedProvider: string | null;
+ setProvidersExpandedProvider: (provider: string | null) => void;
}
const SettingsContext = createContext(null);
@@ -28,14 +37,34 @@ const DEFAULT_SECTION = "general";
export function SettingsProvider(props: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState(DEFAULT_SECTION);
+ const [providersExpandedProvider, setProvidersExpandedProvider] = useState(null);
+
+ const setSection = useCallback((section: string) => {
+ setActiveSection(section);
- const open = useCallback((section?: string) => {
- if (section) setActiveSection(section);
- setIsOpen(true);
+ if (section !== "providers") {
+ setProvidersExpandedProvider(null);
+ }
}, []);
+ const open = useCallback(
+ (section?: string, options?: OpenSettingsOptions) => {
+ if (section) {
+ setSection(section);
+ }
+
+ if (section === "providers") {
+ setProvidersExpandedProvider(options?.expandProvider ?? null);
+ }
+
+ setIsOpen(true);
+ },
+ [setSection]
+ );
+
const close = useCallback(() => {
setIsOpen(false);
+ setProvidersExpandedProvider(null);
}, []);
const value = useMemo(
@@ -44,9 +73,11 @@ export function SettingsProvider(props: { children: ReactNode }) {
activeSection,
open,
close,
- setActiveSection,
+ setActiveSection: setSection,
+ providersExpandedProvider,
+ setProvidersExpandedProvider,
}),
- [isOpen, activeSection, open, close]
+ [isOpen, activeSection, open, close, providersExpandedProvider, setSection]
);
return {props.children} ;
diff --git a/src/browser/contexts/ThemeContext.test.tsx b/src/browser/contexts/ThemeContext.test.tsx
index 238474d136..f04542468f 100644
--- a/src/browser/contexts/ThemeContext.test.tsx
+++ b/src/browser/contexts/ThemeContext.test.tsx
@@ -5,6 +5,7 @@ const dom = new GlobalWindow();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
(global as any).window = dom.window;
(global as any).document = dom.window.document;
+(global as any).location = new URL("https://example.com/");
// Polyfill console since happy-dom might interfere or we just want standard console
(global as any).console = console;
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx
index 5d679ae333..036971d90d 100644
--- a/src/browser/contexts/ThinkingContext.test.tsx
+++ b/src/browser/contexts/ThinkingContext.test.tsx
@@ -30,6 +30,7 @@ const dom = new GlobalWindow();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
(global as any).window = dom.window;
(global as any).document = dom.window.document;
+(global as any).location = new URL("https://example.com/");
// Ensure globals exist for instanceof checks inside usePersistedState
(globalThis as any).StorageEvent = dom.window.StorageEvent;
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts
index 9d5eed0ee4..22615b1eae 100644
--- a/src/browser/stories/mocks/orpc.ts
+++ b/src/browser/stories/mocks/orpc.ts
@@ -349,7 +349,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
status: () => Promise.resolve({ enabled: true, explicit: false }),
},
splashScreens: {
- getViewedSplashScreens: () => Promise.resolve(["mux-gateway-login"]),
+ getViewedSplashScreens: () => Promise.resolve(["onboarding-wizard-v1"]),
markSplashScreenViewed: () => Promise.resolve(undefined),
},
signing: {
diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts
index bec5552bf3..a1ebc3fbae 100644
--- a/src/browser/utils/chatCommands.test.ts
+++ b/src/browser/utils/chatCommands.test.ts
@@ -11,12 +11,29 @@ import {
import type { CommandHandlerContext } from "./chatCommands";
import type { ReviewNoteData } from "@/common/types/review";
-// Simple mock for localStorage to satisfy resolveCompactionModel
+// Simple mock for localStorage to satisfy resolveCompactionModel.
+// Note: resolveCompactionModel reads from window.localStorage (via readPersistedString),
+// so we set both globalThis.localStorage and window.localStorage for test isolation.
beforeEach(() => {
- globalThis.localStorage = {
+ const storage = {
getItem: () => null,
setItem: () => undefined,
+ removeItem: () => undefined,
+ clear: () => undefined,
+ key: () => null,
+ length: 0,
} as unknown as Storage;
+
+ globalThis.localStorage = storage;
+
+ if (typeof window !== "undefined") {
+ try {
+ Object.defineProperty(window, "localStorage", { value: storage, configurable: true });
+ } catch {
+ // Some test DOM environments expose localStorage as a readonly getter.
+ (window as unknown as { localStorage?: Storage }).localStorage = storage;
+ }
+ }
});
describe("parseRuntimeString", () => {
diff --git a/src/browser/utils/gatewayModels.ts b/src/browser/utils/gatewayModels.ts
new file mode 100644
index 0000000000..1b952a37da
--- /dev/null
+++ b/src/browser/utils/gatewayModels.ts
@@ -0,0 +1,26 @@
+import { KNOWN_MODELS } from "@/common/constants/knownModels";
+import type { ProvidersConfigMap } from "@/common/orpc/types";
+import { isProviderSupported } from "@/browser/hooks/useGatewayModels";
+
+const BUILT_IN_MODELS: string[] = Object.values(KNOWN_MODELS).map((model) => model.id);
+
+export function getEligibleGatewayModels(config: ProvidersConfigMap | null): string[] {
+ const customModels: string[] = [];
+
+ if (config) {
+ for (const [provider, providerConfig] of Object.entries(config)) {
+ if (provider === "mux-gateway") continue;
+ for (const modelId of providerConfig.models ?? []) {
+ customModels.push(`${provider}:${modelId}`);
+ }
+ }
+ }
+
+ const unique = new Set();
+ for (const modelId of [...customModels, ...BUILT_IN_MODELS]) {
+ if (!isProviderSupported(modelId)) continue;
+ unique.add(modelId);
+ }
+
+ return Array.from(unique).sort((a, b) => a.localeCompare(b));
+}