From 39ddeefef3db8c3932370160c6d97d01a3387f3d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:02:20 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20onboarding=20w?= =?UTF-8?q?izard=20splash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../splashScreens/OnboardingWizardSplash.tsx | 390 ++++++++++++++++++ src/browser/components/splashScreens/index.ts | 2 + 2 files changed, 392 insertions(+) create mode 100644 src/browser/components/splashScreens/OnboardingWizardSplash.tsx diff --git a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx new file mode 100644 index 0000000000..25c40faebf --- /dev/null +++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx @@ -0,0 +1,390 @@ +import React, { useMemo, useState } from "react"; +import { ArrowLeft, Bot, Command as CommandIcon, Server, Boxes, Sparkles } from "lucide-react"; +import { SplashScreen } from "./SplashScreen"; +import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; +import { DocsLink } from "@/browser/components/DocsLink"; +import { + LocalIcon, + WorktreeIcon, + SSHIcon, + DockerIcon, +} from "@/browser/components/icons/RuntimeIcons"; +import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; +import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; + +const KBD_CLASSNAME = + "bg-background-secondary text-foreground border-border-medium rounded border px-2 py-0.5 font-mono text-xs"; + +type Direction = "forward" | "back"; + +function ProgressDots(props: { count: number; activeIndex: number }) { + return ( +
+ {Array.from({ length: props.count }).map((_, i) => ( + + ))} +
+ ); +} + +function WizardHeader(props: { + stepIndex: number; + totalSteps: number; + onBack: () => void; + hasBack: boolean; +}) { + return ( +
+ {props.hasBack ? ( + + ) : ( + + )} +
+ + {props.stepIndex + 1} / {props.totalSteps} + + +
+
+ ); +} + +function Card(props: { + icon: React.ReactNode; + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ + {props.icon} + + {props.title} +
+
{props.children}
+
+ ); +} + +function CommandPalettePreview(props: { shortcut: string }) { + return ( +
+
+ + Switch workspaces or type > for all commands… + +
+ +
+
+ Recent +
+ +
+
+ Create New Workspace… +
+ + Start a new workspace (Local / Worktree / SSH / Docker) + +
+ + >new + +
+ +
+
+ Open Settings… +
+ + Jump to providers, models, MCP… + +
+ + >settings + +
+ +
+
+ Help: Keybinds +
+ + Discover shortcuts for the whole app + +
+ + {props.shortcut} + +
+
+
+ ); +} + +export function OnboardingWizardSplash(props: { onDismiss: () => void }) { + const [stepIndex, setStepIndex] = useState(0); + const [direction, setDirection] = useState("forward"); + + const commandPaletteShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE); + const agentPickerShortcut = formatKeybind(KEYBINDS.TOGGLE_MODE); + const cycleAgentShortcut = formatKeybind(KEYBINDS.CYCLE_AGENT); + + const steps = useMemo( + () => + [ + { + 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. +

+ +
+
Available providers
+
+ {SUPPORTED_PROVIDERS.map((provider) => ( +
+ +
+ ))} +
+
+ +

+ Configure keys and endpoints in{" "} + Settings → Providers. +

+ + ), + }, + { + 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 +
+ + ), + }, + { + 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="Docker"> + Isolated container per workspace. + +
+ +

+ 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 + +
+ +

+ 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} +
+ +
+ +
+ +

+ Tip: type > for commands and{" "} + / for slash commands. +

+ + ), + }, + ] as const, + [agentPickerShortcut, cycleAgentShortcut, commandPaletteShortcut] + ); + + const totalSteps = steps.length; + const currentStep = steps[stepIndex]; + + const canGoBack = stepIndex > 0; + const canGoForward = 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)); + }; + + return ( + { + if (canGoForward) { + goForward(); + return; + } + props.onDismiss(); + }, + }} + > +
+ + +
+
+ + {currentStep.icon} + + {currentStep.title} +
+ +
{currentStep.body}
+
+
+
+ ); +} diff --git a/src/browser/components/splashScreens/index.ts b/src/browser/components/splashScreens/index.ts index 1c34f6c226..7a22ae4b0b 100644 --- a/src/browser/components/splashScreens/index.ts +++ b/src/browser/components/splashScreens/index.ts @@ -1,4 +1,5 @@ import { LoginWithMuxGatewaySplash } from "./LoginWithMuxGateway"; +import { OnboardingWizardSplash } from "./OnboardingWizardSplash"; export interface SplashConfig { id: string; @@ -12,6 +13,7 @@ export interface SplashConfig { // Priority 2 = Medium priority // 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 }, ]; From ee74dadb6510e5a48ac18c1df4558e05bab8ef12 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:46:01 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20stabilize=20browser?= =?UTF-8?q?=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThemeContext.test.tsx | 1 + src/browser/contexts/ThinkingContext.test.tsx | 1 + src/browser/utils/chatCommands.test.ts | 21 +++++++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) 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/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", () => { From 275fa42dee4ce1fa86b47c9c9372b5335770c4ee Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:22:45 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20remove=20settings=20b?= =?UTF-8?q?utton=20tooltip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/SettingsButton.tsx | 31 ++++++++--------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/browser/components/SettingsButton.tsx b/src/browser/components/SettingsButton.tsx index 26361644dc..bb9d013d5b 100644 --- a/src/browser/components/SettingsButton.tsx +++ b/src/browser/components/SettingsButton.tsx @@ -1,30 +1,21 @@ import { Settings } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; -import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; -import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { Button } from "@/browser/components/ui/button"; export function SettingsButton() { const { open } = useSettings(); return ( - - - - - - Settings ({formatKeybind(KEYBINDS.OPEN_SETTINGS)}) - - + ); } From 734bdecc4919b62d8c2f77d960a01f2649b0ea09 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:24 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fold=20gateway=20spla?= =?UTF-8?q?sh=20into=20onboarding=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../splashScreens/LoginWithMuxGateway.tsx | 365 ----------- .../splashScreens/OnboardingWizardSplash.tsx | 596 ++++++++++++------ .../components/splashScreens/SplashScreen.tsx | 31 +- src/browser/components/splashScreens/index.ts | 2 - src/browser/stories/mocks/orpc.ts | 2 +- 5 files changed, 417 insertions(+), 579 deletions(-) delete mode 100644 src/browser/components/splashScreens/LoginWithMuxGateway.tsx diff --git a/src/browser/components/splashScreens/LoginWithMuxGateway.tsx b/src/browser/components/splashScreens/LoginWithMuxGateway.tsx deleted file mode 100644 index 01d03a5b8a..0000000000 --- a/src/browser/components/splashScreens/LoginWithMuxGateway.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { SplashScreen } from "./SplashScreen"; -import { useAPI } from "@/browser/contexts/API"; -import { getStoredAuthToken } from "@/browser/components/AuthTokenModal"; -import { isProviderSupported } from "@/browser/hooks/useGatewayModels"; -import { getSuggestedModels } from "@/browser/hooks/useModelsFromSettings"; -import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { useSettings } from "@/browser/contexts/SettingsContext"; - -interface OAuthMessage { - type?: unknown; - state?: unknown; - ok?: unknown; - error?: unknown; -} - -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 GATEWAY_CONFIGURED_KEY = "gateway-available"; -type LoginStatus = "idle" | "starting" | "waiting" | "success" | "error"; - -export function LoginWithMuxGatewaySplash(props: { onDismiss: () => void }) { - const { config } = useProvidersConfig(); - - const applyDefaultModelsOnSuccessRef = useRef(false); - const { api } = useAPI(); - const { open: openSettings } = useSettings(); - - const backendBaseUrl = getBackendBaseUrl(); - const backendOrigin = (() => { - try { - return new URL(backendBaseUrl).origin; - } catch { - return window.location.origin; - } - })(); - const isDesktop = !!window.api; - - const [status, setStatus] = useState("idle"); - const [error, setError] = useState(null); - - const loginAttemptRef = useRef(0); - const [desktopFlowId, setDesktopFlowId] = useState(null); - const [serverState, setServerState] = useState(null); - - const handleDismiss = () => { - applyDefaultModelsOnSuccessRef.current = false; - loginAttemptRef.current++; - - if (isDesktop && api && desktopFlowId) { - void api.muxGatewayOauth.cancelDesktopFlow({ flowId: desktopFlowId }); - } - props.onDismiss(); - }; - - const startLogin = async () => { - const attempt = ++loginAttemptRef.current; - - // Enable Mux Gateway for all eligible models after the *first* successful login. - // (If config isn't loaded yet, fall back to the persisted gateway-available state.) - const isLoggedIn = - config?.["mux-gateway"]?.couponCodeSet ?? - readPersistedState(GATEWAY_CONFIGURED_KEY, false); - applyDefaultModelsOnSuccessRef.current = !isLoggedIn; - - try { - setError(null); - - if (isDesktop) { - if (!api) { - setStatus("error"); - setError("Mux API not connected."); - return; - } - - setStatus("starting"); - const startResult = await api.muxGatewayOauth.startDesktopFlow(); - - if (attempt !== loginAttemptRef.current) { - if (startResult.success) { - void api.muxGatewayOauth.cancelDesktopFlow({ flowId: startResult.data.flowId }); - } - return; - } - - if (!startResult.success) { - setStatus("error"); - setError(startResult.error); - return; - } - - const { flowId, authorizeUrl } = startResult.data; - setDesktopFlowId(flowId); - setStatus("waiting"); - - // Desktop main process intercepts external window.open() calls and routes them via shell.openExternal. - if (attempt !== loginAttemptRef.current) { - return; - } - - window.open(authorizeUrl, "_blank", "noopener"); - - const waitResult = await api.muxGatewayOauth.waitForDesktopFlow({ flowId }); - - if (attempt !== loginAttemptRef.current) { - return; - } - - if (waitResult.success) { - if (applyDefaultModelsOnSuccessRef.current) { - let latestConfig = config; - try { - latestConfig = await api.providers.getConfig(); - } catch { - // Ignore errors fetching config; fall back to the current snapshot. - } - - if (attempt !== loginAttemptRef.current) { - return; - } - - updatePersistedState( - GATEWAY_MODELS_KEY, - getSuggestedModels(latestConfig).filter(isProviderSupported) - ); - applyDefaultModelsOnSuccessRef.current = false; - } - - setStatus("success"); - return; - } - - setStatus("error"); - setError(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."); - } - - setStatus("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 !== loginAttemptRef.current) { - popup.close(); - return; - } - - if (typeof json.authorizeUrl !== "string" || typeof json.state !== "string") { - popup.close(); - throw new Error(`Invalid response from ${startUrl.pathname}`); - } - - setServerState(json.state); - popup.location.href = json.authorizeUrl; - setStatus("waiting"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setStatus("error"); - setError(message); - } - }; - - useEffect(() => { - if (isDesktop || status !== "waiting" || !serverState) { - return; - } - - const handleMessage = (event: MessageEvent) => { - if (event.origin !== backendOrigin) return; - - const data = event.data; - if (!data || typeof data !== "object") return; - if (data.type !== "mux-gateway-oauth") return; - if (data.state !== serverState) return; - - if (data.ok === true) { - if (applyDefaultModelsOnSuccessRef.current) { - applyDefaultModelsOnSuccessRef.current = false; - - const applyLatest = (latestConfig: typeof config) => { - updatePersistedState( - GATEWAY_MODELS_KEY, - getSuggestedModels(latestConfig).filter(isProviderSupported) - ); - }; - - if (api) { - api.providers - .getConfig() - .then(applyLatest) - .catch(() => applyLatest(config)); - } else { - applyLatest(config); - } - } - - setStatus("success"); - return; - } - - const msg = typeof data.error === "string" ? data.error : "Login failed"; - setStatus("error"); - setError(msg); - }; - - window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); - }, [isDesktop, status, serverState, backendOrigin, api, config]); - - const isSuccess = status === "success"; - - const primaryLabel = - status === "error" - ? "Try again" - : status === "waiting" || status === "starting" - ? "Waiting for login..." - : "Login with Mux Gateway"; - - const primaryDisabled = status === "waiting" || status === "starting"; - - const dismissLabel = isSuccess - ? null - : status === "waiting" || status === "starting" - ? "Cancel" - : "Not now"; - - return ( - undefined } - : { - label: primaryLabel, - onClick: () => { - void startLogin(); - }, - disabled: primaryDisabled, - } - } - dismissOnPrimaryAction={isSuccess ? undefined : false} - dismissLabel={dismissLabel} - > - {isSuccess ? ( -
-

Login successful.

-
- ) : ( -
-

- Mux Gateway enables you to use free AI tokens from{" "} - - Coder - - . -

- -

You can receive those credits through:

- -
    -
  • - early adopters can request some credits tied to their GH logins on our{" "} - - Discord - -
  • -
  • - vouchers which you can{" "} - - claim here - -
  • -
- -

- You will be able to login through{" "} - {" "} - at any point. -

- - {status === "waiting" &&

Finish the login flow in your browser, then return here.

} - - {status === "error" && error && ( -

- Login failed: {error} -

- )} -
- )} -
- ); -} diff --git a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx index 25c40faebf..de49dd5a7d 100644 --- a/src/browser/components/splashScreens/OnboardingWizardSplash.tsx +++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx @@ -1,20 +1,30 @@ -import React, { useMemo, useState } from "react"; -import { ArrowLeft, Bot, Command as CommandIcon, Server, Boxes, Sparkles } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { ArrowLeft, Bot, Boxes, Command as CommandIcon, Server, Sparkles } from "lucide-react"; import { SplashScreen } from "./SplashScreen"; -import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; import { DocsLink } from "@/browser/components/DocsLink"; +import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; import { + CoderIcon, + DockerIcon, LocalIcon, - WorktreeIcon, SSHIcon, - DockerIcon, + WorktreeIcon, } from "@/browser/components/icons/RuntimeIcons"; +import { Button } from "@/browser/components/ui/button"; +import { useSettings } from "@/browser/contexts/SettingsContext"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; -import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; +import { PROVIDER_DISPLAY_NAMES, SUPPORTED_PROVIDERS } from "@/common/constants/providers"; const KBD_CLASSNAME = "bg-background-secondary text-foreground border-border-medium rounded border px-2 py-0.5 font-mono text-xs"; +interface WizardStep { + key: string; + title: string; + icon: React.ReactNode; + body: React.ReactNode; +} type Direction = "forward" | "back"; function ProgressDots(props: { count: number; activeIndex: number }) { @@ -35,32 +45,13 @@ function ProgressDots(props: { count: number; activeIndex: number }) { ); } -function WizardHeader(props: { - stepIndex: number; - totalSteps: number; - onBack: () => void; - hasBack: boolean; -}) { +function WizardHeader(props: { stepIndex: number; totalSteps: number }) { return ( -
- {props.hasBack ? ( - - ) : ( - - )} -
- - {props.stepIndex + 1} / {props.totalSteps} - - -
+
+ + {props.stepIndex + 1} / {props.totalSteps} + +
); } @@ -152,179 +143,358 @@ 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 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( - () => - [ + const steps = useMemo((): WizardStep[] => { + if (hasConfiguredProvidersAtStart === null) { + return [ { - key: "providers", - title: "Choose your own AI providers", + key: "loading", + title: "Getting started", icon: , body: ( <> -

- Mux is provider-agnostic: bring your own keys, mix and match models, or run locally. -

- -
-
Available providers
-
- {SUPPORTED_PROVIDERS.map((provider) => ( -
- -
- ))} -
-
- -

- Configure keys and endpoints in{" "} - Settings → Providers. -

+

Checking your provider configuration…

), }, - { - 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} + ]; + } + + 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{" "} + + . +

+ + ), + }); + } + + 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 +
+
-
- Agent docs - Plan mode -
- - ), - }, - { - 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="Docker"> - Isolated container per workspace. - +

+ Configure keys and endpoints in{" "} + + . +

+ + ), + }); + + 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 && ( + + )} +
+ +
+ + + +
+ + } >
- +
{props.title} {props.children} - - {props.primaryAction && ( - - )} - {props.dismissLabel !== null && ( - + + {props.footer ?? ( + <> + {props.primaryAction && ( + + )} + {props.dismissLabel !== null && ( + + )} + )} 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 }) {

- + + {muxGatewayLoginInProgress && ( + + )} + + -
+ {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 )} - -
{muxGatewayLoginStatus === "success" &&

Login successful.

}