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..40152dd182 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -3,15 +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 { 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, @@ -49,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; @@ -144,6 +122,8 @@ const PROVIDER_KEY_URLS: Partial> = { }; export function ProvidersSection() { + const { providersExpandedProvider, setProvidersExpandedProvider } = useSettings(); + const { api } = useAPI(); const { config, updateOptimistically } = useProvidersConfig(); @@ -437,6 +417,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/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)}) - - + ); } 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 new file mode 100644 index 0000000000..1ee53bdab2 --- /dev/null +++ b/src/browser/components/splashScreens/OnboardingWizardSplash.tsx @@ -0,0 +1,891 @@ +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"; +import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; +import { + CoderIcon, + DockerIcon, + LocalIcon, + SSHIcon, + WorktreeIcon, +} 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"; + +interface WizardStep { + key: string; + title: string; + icon: React.ReactNode; + body: React.ReactNode; +} +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 }) { + return ( +
+ + {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 { 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( + () => + SUPPORTED_PROVIDERS.filter((provider) => providersConfig?.[provider]?.isConfigured === true), + [providersConfig] + ); + + const configuredProvidersSummary = useMemo(() => { + if (configuredProviders.length === 0) { + return null; + } + + return configuredProviders.map((p) => PROVIDER_DISPLAY_NAMES[p]).join(", "); + }, [configuredProviders]); + + const [hasConfiguredProvidersAtStart, setHasConfiguredProvidersAtStart] = useState< + boolean | null + >(null); + + useEffect(() => { + if (hasConfiguredProvidersAtStart !== null) { + return; + } + + if (providersLoading) { + return; + } + + setHasConfiguredProvidersAtStart(configuredProviders.length > 0); + }, [configuredProviders.length, hasConfiguredProvidersAtStart, providersLoading]); + + const commandPaletteShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE); + const agentPickerShortcut = formatKeybind(KEYBINDS.TOGGLE_MODE); + const cycleAgentShortcut = formatKeybind(KEYBINDS.CYCLE_AGENT); + + const steps = useMemo((): WizardStep[] => { + if (hasConfiguredProvidersAtStart === null) { + return [ + { + key: "loading", + title: "Getting started", + icon: , + body: ( + <> +

Checking your provider configuration…

+ + ), + }, + ]; + } + + const nextSteps: WizardStep[] = []; + + if (hasConfiguredProvidersAtStart === false) { + nextSteps.push({ + key: "mux-gateway", + title: "Mux Gateway (evaluation credits)", + icon: , + body: ( + <> +

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

+ +

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

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

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

+ 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} +
+
+
+ +
+ Agent docs + Plan mode +
+ + ), + }); + + nextSteps.push({ + key: "runtimes", + title: "Multiple runtimes", + icon: , + body: ( + <> +

+ Each workspace can run in the environment that fits the job: keep it local, isolate with + a git worktree, run remotely over SSH, or use a per-workspace Docker container. +

+ +
+ } title="Local"> + Work directly in your project directory. + + } title="Worktree"> + Isolated git worktree under ~/.mux/src. + + } title="SSH"> + Remote clone and commands run on an SSH host. + + } title="Coder (SSH)"> + Use Coder workspaces over SSH for a managed remote dev environment. + + } title="Docker"> + Isolated container per workspace. + +
+ +

You can set a project default runtime in the workspace controls.

+ + ), + }); + + nextSteps.push({ + key: "mcp", + title: "MCP servers", + icon: , + body: ( + <> +

+ MCP servers extend Mux with tools (memory, ticketing, databases, internal APIs). + Configure them per project and optionally override per workspace. +

+ +
+ } title="Project config"> + .mux/mcp.jsonc + + } title="Workspace overrides"> + .mux/mcp.local.jsonc + +
+ +

+ Manage servers in Settings → Projects or via{" "} + /mcp. +

+ + ), + }); + + nextSteps.push({ + key: "palette", + title: "Command palette", + icon: , + body: ( + <> +

+ The command palette is the fastest way to navigate, create workspaces, and discover + features. +

+ +
+ Open command palette + {commandPaletteShortcut} +
+ +
+ +
+ +

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

+ + ), + }); + + return nextSteps; + }, [ + agentPickerShortcut, + cancelMuxGatewayLogin, + commandPaletteShortcut, + configuredProviders.length, + configuredProvidersSummary, + cycleAgentShortcut, + hasConfiguredProvidersAtStart, + muxGatewayLoginButtonLabel, + muxGatewayLoginError, + muxGatewayLoginInProgress, + muxGatewayLoginStatus, + openSettings, + providersConfig, + startMuxGatewayLogin, + ]); + + useEffect(() => { + setStepIndex((index) => Math.min(index, steps.length - 1)); + }, [steps.length]); + + const totalSteps = steps.length; + const currentStep = steps[stepIndex] ?? steps[0]; + + useEffect(() => { + if (currentStep?.key !== "mux-gateway" && muxGatewayLoginInProgress) { + cancelMuxGatewayLogin(); + } + }, [cancelMuxGatewayLogin, currentStep?.key, muxGatewayLoginInProgress]); + + if (!currentStep) { + return null; + } + + const isLoading = hasConfiguredProvidersAtStart === null; + const canGoBack = !isLoading && stepIndex > 0; + const canGoForward = !isLoading && stepIndex < totalSteps - 1; + + const goBack = () => { + if (!canGoBack) { + return; + } + setDirection("back"); + setStepIndex((i) => Math.max(0, i - 1)); + }; + + const goForward = () => { + if (!canGoForward) { + return; + } + setDirection("forward"); + setStepIndex((i) => Math.min(totalSteps - 1, i + 1)); + }; + + const primaryLabel = isLoading ? "Next" : canGoForward ? "Next" : "Done"; + + return ( + { + cancelMuxGatewayLogin(); + props.onDismiss(); + }} + dismissLabel={null} + footerClassName="justify-between" + footer={ + <> +
+ {canGoBack && ( + + )} +
+ +
+ + + +
+ + } + > +
+ + +
+
+ + {currentStep.icon} + + {currentStep.title} +
+ +
{currentStep.body}
+
+
+
+ ); +} diff --git a/src/browser/components/splashScreens/SplashScreen.tsx b/src/browser/components/splashScreens/SplashScreen.tsx index 71c42fa899..7782d9d82d 100644 --- a/src/browser/components/splashScreens/SplashScreen.tsx +++ b/src/browser/components/splashScreens/SplashScreen.tsx @@ -21,6 +21,10 @@ interface SplashScreenProps { dismissOnPrimaryAction?: boolean; /** Defaults to "Got it". Set to null to hide the dismiss button entirely. */ dismissLabel?: string | null; + /** Optional custom footer content (replaces primary + dismiss buttons). */ + footer?: React.ReactNode; + /** Optional class name for the DialogFooter container. */ + footerClassName?: string; } export function SplashScreen(props: SplashScreenProps) { @@ -43,16 +47,23 @@ export function SplashScreen(props: SplashScreenProps) { {props.title} {props.children} - - {props.primaryAction && ( - - )} - {props.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 1c34f6c226..0a3f8b9060 100644 --- a/src/browser/components/splashScreens/index.ts +++ b/src/browser/components/splashScreens/index.ts @@ -1,4 +1,4 @@ -import { LoginWithMuxGatewaySplash } from "./LoginWithMuxGateway"; +import { OnboardingWizardSplash } from "./OnboardingWizardSplash"; export interface SplashConfig { id: string; @@ -12,7 +12,7 @@ export interface SplashConfig { // Priority 2 = Medium priority // Priority 3+ = Higher priority (shown first) export const SPLASH_REGISTRY: SplashConfig[] = [ - { id: "mux-gateway-login", priority: 4, component: LoginWithMuxGatewaySplash }, + { id: "onboarding-wizard-v1", priority: 5, component: OnboardingWizardSplash }, // Future: { id: "new-feature-xyz", priority: 2, component: NewFeatureSplash }, ]; diff --git a/src/browser/contexts/SettingsContext.tsx b/src/browser/contexts/SettingsContext.tsx index 4041520c97..d0fd2359ae 100644 --- a/src/browser/contexts/SettingsContext.tsx +++ b/src/browser/contexts/SettingsContext.tsx @@ -7,12 +7,21 @@ import React, { type ReactNode, } from "react"; +interface OpenSettingsOptions { + /** When opening the Providers settings, expand the given provider. */ + expandProvider?: string; +} + interface SettingsContextValue { isOpen: boolean; activeSection: string; - open: (section?: string) => void; + open: (section?: string, options?: OpenSettingsOptions) => void; close: () => void; setActiveSection: (section: string) => void; + + /** One-shot hint for ProvidersSection to expand a provider. */ + providersExpandedProvider: string | null; + setProvidersExpandedProvider: (provider: string | null) => void; } const SettingsContext = createContext(null); @@ -28,14 +37,34 @@ const DEFAULT_SECTION = "general"; export function SettingsProvider(props: { children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const [activeSection, setActiveSection] = useState(DEFAULT_SECTION); + const [providersExpandedProvider, setProvidersExpandedProvider] = useState(null); + + const setSection = useCallback((section: string) => { + setActiveSection(section); - const open = useCallback((section?: string) => { - if (section) setActiveSection(section); - setIsOpen(true); + if (section !== "providers") { + setProvidersExpandedProvider(null); + } }, []); + const open = useCallback( + (section?: string, options?: OpenSettingsOptions) => { + if (section) { + setSection(section); + } + + if (section === "providers") { + setProvidersExpandedProvider(options?.expandProvider ?? null); + } + + setIsOpen(true); + }, + [setSection] + ); + const close = useCallback(() => { setIsOpen(false); + setProvidersExpandedProvider(null); }, []); const value = useMemo( @@ -44,9 +73,11 @@ export function SettingsProvider(props: { children: ReactNode }) { activeSection, open, close, - setActiveSection, + setActiveSection: setSection, + providersExpandedProvider, + setProvidersExpandedProvider, }), - [isOpen, activeSection, open, close] + [isOpen, activeSection, open, close, providersExpandedProvider, setSection] ); return {props.children}; diff --git a/src/browser/contexts/ThemeContext.test.tsx b/src/browser/contexts/ThemeContext.test.tsx index 238474d136..f04542468f 100644 --- a/src/browser/contexts/ThemeContext.test.tsx +++ b/src/browser/contexts/ThemeContext.test.tsx @@ -5,6 +5,7 @@ const dom = new GlobalWindow(); /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ (global as any).window = dom.window; (global as any).document = dom.window.document; +(global as any).location = new URL("https://example.com/"); // Polyfill console since happy-dom might interfere or we just want standard console (global as any).console = console; /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 5d679ae333..036971d90d 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -30,6 +30,7 @@ const dom = new GlobalWindow(); /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ (global as any).window = dom.window; (global as any).document = dom.window.document; +(global as any).location = new URL("https://example.com/"); // Ensure globals exist for instanceof checks inside usePersistedState (globalThis as any).StorageEvent = dom.window.StorageEvent; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 9d5eed0ee4..22615b1eae 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -349,7 +349,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl status: () => Promise.resolve({ enabled: true, explicit: false }), }, splashScreens: { - getViewedSplashScreens: () => Promise.resolve(["mux-gateway-login"]), + getViewedSplashScreens: () => Promise.resolve(["onboarding-wizard-v1"]), markSplashScreenViewed: () => Promise.resolve(undefined), }, signing: { diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index bec5552bf3..a1ebc3fbae 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -11,12 +11,29 @@ import { import type { CommandHandlerContext } from "./chatCommands"; import type { ReviewNoteData } from "@/common/types/review"; -// Simple mock for localStorage to satisfy resolveCompactionModel +// Simple mock for localStorage to satisfy resolveCompactionModel. +// Note: resolveCompactionModel reads from window.localStorage (via readPersistedString), +// so we set both globalThis.localStorage and window.localStorage for test isolation. beforeEach(() => { - globalThis.localStorage = { + const storage = { getItem: () => null, setItem: () => undefined, + removeItem: () => undefined, + clear: () => undefined, + key: () => null, + length: 0, } as unknown as Storage; + + globalThis.localStorage = storage; + + if (typeof window !== "undefined") { + try { + Object.defineProperty(window, "localStorage", { value: storage, configurable: true }); + } catch { + // Some test DOM environments expose localStorage as a readonly getter. + (window as unknown as { localStorage?: Storage }).localStorage = storage; + } + } }); describe("parseRuntimeString", () => { diff --git a/src/browser/utils/gatewayModels.ts b/src/browser/utils/gatewayModels.ts new file mode 100644 index 0000000000..1b952a37da --- /dev/null +++ b/src/browser/utils/gatewayModels.ts @@ -0,0 +1,26 @@ +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { isProviderSupported } from "@/browser/hooks/useGatewayModels"; + +const BUILT_IN_MODELS: string[] = Object.values(KNOWN_MODELS).map((model) => model.id); + +export function getEligibleGatewayModels(config: ProvidersConfigMap | null): string[] { + const customModels: string[] = []; + + if (config) { + for (const [provider, providerConfig] of Object.entries(config)) { + if (provider === "mux-gateway") continue; + for (const modelId of providerConfig.models ?? []) { + customModels.push(`${provider}:${modelId}`); + } + } + } + + const unique = new Set(); + for (const modelId of [...customModels, ...BUILT_IN_MODELS]) { + if (!isProviderSupported(modelId)) continue; + unique.add(modelId); + } + + return Array.from(unique).sort((a, b) => a.localeCompare(b)); +}