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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { AuthTokenModal } from "@/browser/components/AuthTokenModal";

import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
import { SettingsModal } from "./components/Settings/SettingsModal";
import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider";
import { TutorialProvider } from "./contexts/TutorialContext";
import { TooltipProvider } from "./components/ui/tooltip";
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
Expand Down Expand Up @@ -719,11 +720,13 @@ function App() {
<ExperimentsProvider>
<TooltipProvider delayDuration={200}>
<SettingsProvider>
<TutorialProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
</TutorialProvider>
<SplashScreenProvider>
<TutorialProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
</TutorialProvider>
</SplashScreenProvider>
</SettingsProvider>
</TooltipProvider>
</ExperimentsProvider>
Expand Down
36 changes: 36 additions & 0 deletions src/browser/components/splashScreens/MuxGateway.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from "react";
import { SplashScreen } from "./SplashScreen";
import { useSettings } from "@/browser/contexts/SettingsContext";

export function MuxGatewaySplash({ onDismiss }: { onDismiss: () => void }) {
const { open: openSettings } = useSettings();

const handleOpenSettings = () => {
openSettings("providers");
};

return (
<SplashScreen
title="Introducing Mux Gateway"
onDismiss={onDismiss}
primaryAction={{ label: "Open Settings", onClick: handleOpenSettings }}
>
<div className="text-muted" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<p>Mux Gateway gives you access to AI models through a unified API.</p>
<p>
If you haven&apos;t redeemed your Mux voucher yet,{" "}
<a
href="https://gateway.mux.coder.com/"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
claim it here
</a>
.
</p>
<p>Once redeemed, add your coupon code in Settings → Providers → Mux Gateway.</p>
</div>
</SplashScreen>
);
}
48 changes: 48 additions & 0 deletions src/browser/components/splashScreens/SplashScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/browser/components/ui/dialog";
import { Button } from "@/browser/components/ui/button";

interface SplashScreenProps {
title: string;
children: React.ReactNode;
onDismiss: () => void;
primaryAction?: {
label: string;
onClick: () => void;
};
dismissLabel?: string; // defaults to "Got it"
}

export function SplashScreen(props: SplashScreenProps) {
const handlePrimaryAction = () => {
if (props.primaryAction) {
props.primaryAction.onClick();
}
props.onDismiss();
};

return (
<Dialog open onOpenChange={(open) => !open && props.onDismiss()}>
<DialogContent maxWidth="500px">
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
</DialogHeader>
{props.children}
<DialogFooter>
{props.primaryAction && (
<Button onClick={handlePrimaryAction}>{props.primaryAction.label}</Button>
)}
<Button variant="secondary" onClick={props.onDismiss}>
{props.dismissLabel ?? "Got it"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
69 changes: 69 additions & 0 deletions src/browser/components/splashScreens/SplashScreenProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
import { SPLASH_REGISTRY, DISABLE_SPLASH_SCREENS, type SplashConfig } from "./index";
import { useAPI } from "@/browser/contexts/API";

export function SplashScreenProvider({ children }: { children: ReactNode }) {
const { api } = useAPI();
const [queue, setQueue] = useState<SplashConfig[]>([]);
const [loaded, setLoaded] = useState(false);

// Load viewed splash screens from config on mount
useEffect(() => {
// Skip if disabled or API not ready
if (DISABLE_SPLASH_SCREENS || !api) {
setLoaded(true);
return;
}

void (async () => {
try {
const viewedIds = await api.splashScreens.getViewedSplashScreens();

// Filter registry to undismissed splashes, sorted by priority (highest number first)
const activeQueue = SPLASH_REGISTRY.filter((splash) => {
// Priority 0 = never show
if (splash.priority === 0) return false;

// Check if this splash has been viewed
return !viewedIds.includes(splash.id);
}).sort((a, b) => b.priority - a.priority); // Higher number = higher priority = shown first

setQueue(activeQueue);
} catch (error) {
console.error("Failed to load viewed splash screens:", error);
// On error, don't show any splash screens
setQueue([]);
} finally {
setLoaded(true);
}
})();
}, [api]);

const currentSplash = queue[0] ?? null;

const dismiss = useCallback(async () => {
if (!currentSplash || !api) return;

// Mark as viewed in config
try {
await api.splashScreens.markSplashScreenViewed({ splashId: currentSplash.id });
} catch (error) {
console.error("Failed to mark splash screen as viewed:", error);
}

// Remove from queue, next one shows automatically
setQueue((q) => q.slice(1));
}, [currentSplash, api]);

// Don't render splash until we've loaded the viewed state
if (!loaded) {
return <>{children}</>;
}

return (
<>
{children}
{currentSplash && <currentSplash.component onDismiss={() => void dismiss()} />}
</>
);
}
20 changes: 20 additions & 0 deletions src/browser/components/splashScreens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MuxGatewaySplash } from "./MuxGateway";

export interface SplashConfig {
id: string;
priority: number;
component: React.FC<{ onDismiss: () => void }>;
}

// Add new splash screens here
// Priority 0 = Never show
// Priority 1 = Lowest priority
// Priority 2 = Medium priority
// Priority 3+ = Higher priority (shown first)
export const SPLASH_REGISTRY: SplashConfig[] = [
{ id: "mux-gateway-intro", priority: 3, component: MuxGatewaySplash },
// Future: { id: "new-feature-xyz", priority: 2, component: NewFeatureSplash },
];

// Set to true to disable all splash screens (useful for testing)
export const DISABLE_SPLASH_SCREENS = true;
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export {
providers,
ProvidersConfigMapSchema,
server,
splashScreens,
telemetry,
TelemetryEventSchema,
terminal,
Expand Down
14 changes: 14 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,20 @@ export const server = {
},
};

// Splash screens
export const splashScreens = {
getViewedSplashScreens: {
input: z.void(),
output: z.array(z.string()),
},
markSplashScreenViewed: {
input: z.object({
splashId: z.string(),
}),
output: z.void(),
},
};

// Update
export const update = {
check: {
Expand Down
13 changes: 13 additions & 0 deletions src/common/orpc/schemas/splashScreens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from "zod";

export const getViewedSplashScreens = {
input: z.undefined(),
output: z.array(z.string()),
};

export const markSplashScreenViewed = {
input: z.object({
splashId: z.string(),
}),
output: z.undefined(),
};
2 changes: 2 additions & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export interface ProjectsConfig {
projects: Map<string, ProjectConfig>;
/** SSH hostname/alias for this machine (used for editor deep links in browser mode) */
serverSshHost?: string;
/** IDs of splash screens that have been viewed */
viewedSplashScreens?: string[];
}
16 changes: 14 additions & 2 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export class Config {
try {
if (fs.existsSync(this.configFile)) {
const data = fs.readFileSync(this.configFile, "utf-8");
const parsed = JSON.parse(data) as { projects?: unknown; serverSshHost?: string };
const parsed = JSON.parse(data) as {
projects?: unknown;
serverSshHost?: string;
viewedSplashScreens?: string[];
};

// Config is stored as array of [path, config] pairs
if (parsed.projects && Array.isArray(parsed.projects)) {
Expand All @@ -67,6 +71,7 @@ export class Config {
return {
projects: projectsMap,
serverSshHost: parsed.serverSshHost,
viewedSplashScreens: parsed.viewedSplashScreens,
};
}
}
Expand All @@ -86,12 +91,19 @@ export class Config {
fs.mkdirSync(this.rootDir, { recursive: true });
}

const data: { projects: Array<[string, ProjectConfig]>; serverSshHost?: string } = {
const data: {
projects: Array<[string, ProjectConfig]>;
serverSshHost?: string;
viewedSplashScreens?: string[];
} = {
projects: Array.from(config.projects.entries()),
};
if (config.serverSshHost) {
data.serverSshHost = config.serverSshHost;
}
if (config.viewedSplashScreens) {
data.viewedSplashScreens = config.viewedSplashScreens;
}

await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8");
} catch (error) {
Expand Down
24 changes: 24 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ export const router = (authToken?: string) => {
return context.tokenizerService.calculateStats(input.messages, input.model);
}),
},
splashScreens: {
getViewedSplashScreens: t
.input(schemas.splashScreens.getViewedSplashScreens.input)
.output(schemas.splashScreens.getViewedSplashScreens.output)
.handler(({ context }) => {
const config = context.config.loadConfigOrDefault();
return config.viewedSplashScreens ?? [];
}),
markSplashScreenViewed: t
.input(schemas.splashScreens.markSplashScreenViewed.input)
.output(schemas.splashScreens.markSplashScreenViewed.output)
.handler(async ({ context, input }) => {
await context.config.editConfig((config) => {
const viewed = config.viewedSplashScreens ?? [];
if (!viewed.includes(input.splashId)) {
viewed.push(input.splashId);
}
return {
...config,
viewedSplashScreens: viewed,
};
});
}),
},
server: {
getLaunchProject: t
.input(schemas.server.getLaunchProject.input)
Expand Down