} 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.
-
-
-
-
Agent picker
-
{agentPickerShortcut}
-
•
-
Cycle agent
-
{cycleAgentShortcut}
+ ];
+ }
+
+ 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.
+
+
+
+
+
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 (
+
-
-
+ );
+ })}
+
+
+
+
+ Configured
+
+ Not configured
+
+
- >
- ),
- },
- {
- key: "runtimes",
- title: "Multiple runtimes",
- icon:
- 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="Docker">
- Isolated container per workspace.
-
+
+ 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}
+
+
-
- You can set a project default runtime in the workspace creation controls.
-
- >
- ),
- },
- {
- 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
-
-
+
+ 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.
+
+
-
- Manage servers in Settings → Projects or
- via /mcp.
-
- >
- ),
- },
- {
- 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}
-
+
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.
-
- >
- ),
- },
- ] as const,
- [agentPickerShortcut, cycleAgentShortcut, commandPaletteShortcut]
- );
+
+
+
+
+
+ Tip: type > for commands and{" "}
+ / for slash commands.
+
+ >
+ ),
+ });
+
+ return nextSteps;
+ }, [
+ agentPickerShortcut,
+ commandPaletteShortcut,
+ configuredProviders.length,
+ configuredProvidersSummary,
+ cycleAgentShortcut,
+ hasConfiguredProvidersAtStart,
+ openSettings,
+ providersConfig,
+ ]);
+
+ useEffect(() => {
+ setStepIndex((index) => Math.min(index, steps.length - 1));
+ }, [steps.length]);
const totalSteps = steps.length;
- const currentStep = steps[stepIndex];
+ const currentStep = steps[stepIndex] ?? steps[0];
+
+ if (!currentStep) {
+ return null;
+ }
- const canGoBack = stepIndex > 0;
- const canGoForward = stepIndex < totalSteps - 1;
+ const isLoading = hasConfiguredProvidersAtStart === null;
+ const canGoBack = !isLoading && stepIndex > 0;
+ const canGoForward = !isLoading && stepIndex < totalSteps - 1;
const goBack = () => {
if (!canGoBack) {
@@ -342,30 +512,54 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
setStepIndex((i) => Math.min(totalSteps - 1, i + 1));
};
+ const primaryLabel = isLoading ? "Next" : canGoForward ? "Next" : "Done";
+
return (
{
- if (canGoForward) {
- goForward();
- return;
- }
- props.onDismiss();
- },
- }}
+ dismissLabel={null}
+ footerClassName="justify-between"
+ footer={
+ <>
+
+ {canGoBack && (
+
+
+ Back
+
+ )}
+
+
+
+ {
+ if (isLoading) {
+ return;
+ }
+
+ if (canGoForward) {
+ goForward();
+ return;
+ }
+
+ props.onDismiss();
+ }}
+ disabled={isLoading}
+ >
+ {primaryLabel}
+
+
+
+ Skip
+
+
+ >
+ }
>
-
+
{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 7a22ae4b0b..0a3f8b9060 100644
--- a/src/browser/components/splashScreens/index.ts
+++ b/src/browser/components/splashScreens/index.ts
@@ -1,4 +1,3 @@
-import { LoginWithMuxGatewaySplash } from "./LoginWithMuxGateway";
import { OnboardingWizardSplash } from "./OnboardingWizardSplash";
export interface SplashConfig {
@@ -14,7 +13,6 @@ export interface SplashConfig {
// Priority 3+ = Higher priority (shown first)
export const SPLASH_REGISTRY: SplashConfig[] = [
{ id: "onboarding-wizard-v1", priority: 5, component: OnboardingWizardSplash },
- { id: "mux-gateway-login", priority: 4, component: LoginWithMuxGatewaySplash },
// Future: { id: "new-feature-xyz", priority: 2, component: NewFeatureSplash },
];
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts
index 9d5eed0ee4..b8e3c8db9c 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([]),
markSplashScreenViewed: () => Promise.resolve(undefined),
},
signing: {
From 775f288f86c9e76d70f9420629041d8819c6c074 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 16 Jan 2026 16:31:14 +0000
Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20onboarding=20s?=
=?UTF-8?q?plash=20in=20storybook=20mocks?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/browser/stories/mocks/orpc.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts
index b8e3c8db9c..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([]),
+ getViewedSplashScreens: () => Promise.resolve(["onboarding-wizard-v1"]),
markSplashScreenViewed: () => Promise.resolve(undefined),
},
signing: {
From 1c8d02aa27648e25f8d34d634a1abead4fb43e13 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 16 Jan 2026 16:52:48 +0000
Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20open=20provider=20se?=
=?UTF-8?q?ttings=20from=20onboarding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/browser/assets/icons/ollama.svg | 27 ++++++++++++
src/browser/assets/icons/openrouter.svg | 21 +++++++++
src/browser/components/ProviderIcon.tsx | 4 ++
.../Settings/sections/ProvidersSection.tsx | 12 ++++++
.../splashScreens/OnboardingWizardSplash.tsx | 8 ++--
src/browser/contexts/SettingsContext.tsx | 43 ++++++++++++++++---
6 files changed, 106 insertions(+), 9 deletions(-)
create mode 100644 src/browser/assets/icons/ollama.svg
create mode 100644 src/browser/assets/icons/openrouter.svg
diff --git a/src/browser/assets/icons/ollama.svg b/src/browser/assets/icons/ollama.svg
new file mode 100644
index 0000000000..4b8c219504
--- /dev/null
+++ b/src/browser/assets/icons/ollama.svg
@@ -0,0 +1,27 @@
+
+
+ Ollama icon
+
+
+
+
+
+
diff --git a/src/browser/assets/icons/openrouter.svg b/src/browser/assets/icons/openrouter.svg
new file mode 100644
index 0000000000..c827c7af42
--- /dev/null
+++ b/src/browser/assets/icons/openrouter.svg
@@ -0,0 +1,21 @@
+
+
+ OpenRouter icon
+
+
+
+
+
+
diff --git a/src/browser/components/ProviderIcon.tsx b/src/browser/components/ProviderIcon.tsx
index b56346f853..a060efa7ef 100644
--- a/src/browser/components/ProviderIcon.tsx
+++ b/src/browser/components/ProviderIcon.tsx
@@ -3,6 +3,8 @@ import AnthropicIcon from "@/browser/assets/icons/anthropic.svg?react";
import OpenAIIcon from "@/browser/assets/icons/openai.svg?react";
import GoogleIcon from "@/browser/assets/icons/google.svg?react";
import XAIIcon from "@/browser/assets/icons/xai.svg?react";
+import OpenRouterIcon from "@/browser/assets/icons/openrouter.svg?react";
+import OllamaIcon from "@/browser/assets/icons/ollama.svg?react";
import DeepSeekIcon from "@/browser/assets/icons/deepseek.svg?react";
import AWSIcon from "@/browser/assets/icons/aws.svg?react";
import { GatewayIcon } from "@/browser/components/icons/GatewayIcon";
@@ -23,7 +25,9 @@ const PROVIDER_ICONS: Partial> = {
google: GoogleIcon,
xai: XAIIcon,
deepseek: DeepSeekIcon,
+ openrouter: OpenRouterIcon,
bedrock: AWSIcon,
+ ollama: OllamaIcon,
"mux-gateway": GatewayIcon,
};
diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx
index 3c8a541446..abdedd0252 100644
--- a/src/browser/components/Settings/sections/ProvidersSection.tsx
+++ b/src/browser/components/Settings/sections/ProvidersSection.tsx
@@ -7,6 +7,7 @@ import { KNOWN_MODELS } from "@/common/constants/knownModels";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import type { ProviderName } from "@/common/constants/providers";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
+import { useSettings } from "@/browser/contexts/SettingsContext";
import { useAPI } from "@/browser/contexts/API";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { getStoredAuthToken } from "@/browser/components/AuthTokenModal";
@@ -144,6 +145,8 @@ const PROVIDER_KEY_URLS: Partial> = {
};
export function ProvidersSection() {
+ const { providersExpandedProvider, setProvidersExpandedProvider } = useSettings();
+
const { api } = useAPI();
const { config, updateOptimistically } = useProvidersConfig();
@@ -437,6 +440,15 @@ export function ProvidersSection() {
: "Login to Mux Gateway";
const [expandedProvider, setExpandedProvider] = useState(null);
+
+ useEffect(() => {
+ if (!providersExpandedProvider) {
+ return;
+ }
+
+ setExpandedProvider(providersExpandedProvider);
+ setProvidersExpandedProvider(null);
+ }, [providersExpandedProvider, setProvidersExpandedProvider]);
const [editingField, setEditingField] = useState<{
provider: string;
field: string;
diff --git a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
index de49dd5a7d..5f7d6ab60f 100644
--- a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
+++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
@@ -303,10 +303,12 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
const configured = providersConfig?.[provider]?.isConfigured === true;
return (
- openSettings("providers", { expandProvider: provider })}
>
void }) {
configured ? "bg-green-500" : "bg-border-medium"
}`}
/>
-
+
);
})}
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} ;
From 56f6e48dc971c95c9d2b279332ba8f1eae29cf2f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 16 Jan 2026 17:20:21 +0000
Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20Mux=20Gateway?=
=?UTF-8?q?=20login=20to=20onboarding=20wizard?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Settings/sections/ProvidersSection.tsx | 31 +-
.../splashScreens/OnboardingWizardSplash.tsx | 325 +++++++++++++++++-
src/browser/utils/gatewayModels.ts | 26 ++
3 files changed, 348 insertions(+), 34 deletions(-)
create mode 100644 src/browser/utils/gatewayModels.ts
diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx
index abdedd0252..40152dd182 100644
--- a/src/browser/components/Settings/sections/ProvidersSection.tsx
+++ b/src/browser/components/Settings/sections/ProvidersSection.tsx
@@ -3,16 +3,16 @@ import { ChevronDown, ChevronRight, Check, X, Eye, EyeOff, ExternalLink } from "
import { createEditKeyHandler } from "@/browser/utils/ui/keybinds";
import { SUPPORTED_PROVIDERS } from "@/common/constants/providers";
-import { KNOWN_MODELS } from "@/common/constants/knownModels";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import type { ProviderName } from "@/common/constants/providers";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
-import { useSettings } from "@/browser/contexts/SettingsContext";
+import { getStoredAuthToken } from "@/browser/components/AuthTokenModal";
import { useAPI } from "@/browser/contexts/API";
+import { useSettings } from "@/browser/contexts/SettingsContext";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
-import { getStoredAuthToken } from "@/browser/components/AuthTokenModal";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
-import { isProviderSupported, useGateway } from "@/browser/hooks/useGatewayModels";
+import { useGateway } from "@/browser/hooks/useGatewayModels";
+import { getEligibleGatewayModels } from "@/browser/utils/gatewayModels";
import { Button } from "@/browser/components/ui/button";
import {
Select,
@@ -50,29 +50,6 @@ function getBackendBaseUrl(): string {
}
const GATEWAY_MODELS_KEY = "gateway-models";
-const BUILT_IN_MODELS: string[] = Object.values(KNOWN_MODELS).map((model) => model.id);
-
-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));
-}
-
interface FieldConfig {
key: string;
label: string;
diff --git a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
index 5f7d6ab60f..fbe121195e 100644
--- a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
+++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Bot, Boxes, Command as CommandIcon, Server, Sparkles } from "lucide-react";
import { SplashScreen } from "./SplashScreen";
import { DocsLink } from "@/browser/components/DocsLink";
@@ -12,10 +12,36 @@ import {
} from "@/browser/components/icons/RuntimeIcons";
import { Button } from "@/browser/components/ui/button";
import { useSettings } from "@/browser/contexts/SettingsContext";
+import { getStoredAuthToken } from "@/browser/components/AuthTokenModal";
+import { useAPI } from "@/browser/contexts/API";
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
+import { getEligibleGatewayModels } from "@/browser/utils/gatewayModels";
+import type { ProvidersConfigMap } from "@/common/orpc/types";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds";
import { PROVIDER_DISPLAY_NAMES, SUPPORTED_PROVIDERS } from "@/common/constants/providers";
+interface OAuthMessage {
+ type?: unknown;
+ state?: unknown;
+ ok?: unknown;
+ error?: unknown;
+}
+
+type MuxGatewayLoginStatus = "idle" | "starting" | "waiting" | "success" | "error";
+
+function getServerAuthToken(): string | null {
+ const urlToken = new URLSearchParams(window.location.search).get("token")?.trim();
+ return urlToken?.length ? urlToken : getStoredAuthToken();
+}
+
+function getBackendBaseUrl(): string {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
+ // @ts-ignore - import.meta is available in Vite
+ return import.meta.env.VITE_BACKEND_URL ?? window.location.origin;
+}
+
+const GATEWAY_MODELS_KEY = "gateway-models";
const KBD_CLASSNAME =
"bg-background-secondary text-foreground border-border-medium rounded border px-2 py-0.5 font-mono text-xs";
@@ -141,10 +167,256 @@ function CommandPalettePreview(props: { shortcut: string }) {
export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
const [stepIndex, setStepIndex] = useState(0);
- const [direction, setDirection] = useState("forward");
const { open: openSettings } = useSettings();
const { config: providersConfig, loading: providersLoading } = useProvidersConfig();
+ const [direction, setDirection] = useState("forward");
+
+ const { api } = useAPI();
+
+ const backendBaseUrl = getBackendBaseUrl();
+ const backendOrigin = useMemo(() => {
+ try {
+ return new URL(backendBaseUrl).origin;
+ } catch {
+ return window.location.origin;
+ }
+ }, [backendBaseUrl]);
+
+ const isDesktop = !!window.api;
+
+ const [muxGatewayLoginStatus, setMuxGatewayLoginStatus] = useState("idle");
+ const [muxGatewayLoginError, setMuxGatewayLoginError] = useState(null);
+
+ const muxGatewayApplyDefaultModelsOnSuccessRef = useRef(false);
+ const muxGatewayLoginAttemptRef = useRef(0);
+ const [muxGatewayDesktopFlowId, setMuxGatewayDesktopFlowId] = useState(null);
+ const [muxGatewayServerState, setMuxGatewayServerState] = useState(null);
+
+ const cancelMuxGatewayLogin = useCallback(() => {
+ muxGatewayApplyDefaultModelsOnSuccessRef.current = false;
+ 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;
+
+ // Enable Mux Gateway for all eligible models after the *first* successful login.
+ const isLoggedIn = providersConfig?.["mux-gateway"]?.couponCodeSet ?? false;
+ muxGatewayApplyDefaultModelsOnSuccessRef.current = !isLoggedIn;
+
+ try {
+ setMuxGatewayLoginError(null);
+ setMuxGatewayDesktopFlowId(null);
+ setMuxGatewayServerState(null);
+
+ if (isDesktop) {
+ if (!api) {
+ setMuxGatewayLoginStatus("error");
+ setMuxGatewayLoginError("Mux API not connected.");
+ return;
+ }
+
+ setMuxGatewayLoginStatus("starting");
+ const startResult = await api.muxGatewayOauth.startDesktopFlow();
+
+ if (attempt !== muxGatewayLoginAttemptRef.current) {
+ if (startResult.success) {
+ void api.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 api.muxGatewayOauth.waitForDesktopFlow({ flowId });
+
+ if (attempt !== muxGatewayLoginAttemptRef.current) {
+ return;
+ }
+
+ if (waitResult.success) {
+ if (muxGatewayApplyDefaultModelsOnSuccessRef.current) {
+ let latestConfig: ProvidersConfigMap | null = providersConfig;
+ try {
+ latestConfig = await api.providers.getConfig();
+ } catch {
+ // Ignore errors fetching config; fall back to the current snapshot.
+ }
+
+ if (attempt !== muxGatewayLoginAttemptRef.current) {
+ return;
+ }
+
+ updatePersistedState(GATEWAY_MODELS_KEY, getEligibleGatewayModels(latestConfig));
+ muxGatewayApplyDefaultModelsOnSuccessRef.current = false;
+ }
+
+ setMuxGatewayLoginStatus("success");
+ 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("/auth/mux-gateway/start", backendBaseUrl);
+ 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 = err instanceof Error ? err.message : String(err);
+ setMuxGatewayLoginStatus("error");
+ setMuxGatewayLoginError(message);
+ }
+ }, [api, backendBaseUrl, isDesktop, providersConfig]);
+
+ useEffect(() => {
+ const attempt = muxGatewayLoginAttemptRef.current;
+
+ if (isDesktop || muxGatewayLoginStatus !== "waiting" || !muxGatewayServerState) {
+ return;
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ 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(
() =>
@@ -224,16 +496,40 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
-
+ {
+ void startMuxGatewayLogin();
+ }}
+ disabled={muxGatewayLoginInProgress}
+ >
+ {muxGatewayLoginButtonLabel}
+
+
+ {muxGatewayLoginInProgress && (
+
+ Cancel
+
+ )}
+
+
Open Mux Gateway
- openSettings("providers")}>
- Open Provider Settings
-
+ {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:
@@ -474,13 +770,19 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
return nextSteps;
}, [
agentPickerShortcut,
+ cancelMuxGatewayLogin,
commandPaletteShortcut,
configuredProviders.length,
configuredProvidersSummary,
cycleAgentShortcut,
hasConfiguredProvidersAtStart,
+ muxGatewayLoginButtonLabel,
+ muxGatewayLoginError,
+ muxGatewayLoginInProgress,
+ muxGatewayLoginStatus,
openSettings,
providersConfig,
+ startMuxGatewayLogin,
]);
useEffect(() => {
@@ -490,6 +792,12 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
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;
}
@@ -519,7 +827,10 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
return (
{
+ cancelMuxGatewayLogin();
+ props.onDismiss();
+ }}
dismissLabel={null}
footerClassName="justify-between"
footer={
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));
+}
From 27a700611114f045a6167368859fc561f21877b8 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 16 Jan 2026 17:28:06 +0000
Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20drop=20extra=20Mux=20?=
=?UTF-8?q?Gateway=20link=20in=20onboarding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/splashScreens/OnboardingWizardSplash.tsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
index fbe121195e..1ee53bdab2 100644
--- a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
+++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx
@@ -510,12 +510,6 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
Cancel
)}
-
-
-
- Open Mux Gateway
-
-
{muxGatewayLoginStatus === "success" && Login successful.
}