Skip to content

Commit a92f3e8

Browse files
authored
🤖 feat: add splash screen system with Mux Gateway announcement (#1168)
## Overview Implements a reusable splash screen system for feature announcements with automatic queuing and persistence. ## Features - **Queue System** - Shows multiple splash screens one at a time based on priority - **Persistence** - Uses localStorage to track dismissed splashes (never shown again) - **Priority System** - Higher numbers = higher priority (3 shown before 2), priority 0 = never show - **Mux Gateway Splash** - First implementation promotes Mux Gateway with: - External link to redeem voucher: https://gateway.mux.coder.com/ - "Open Settings" button that navigates to Settings → Providers - Clean design without unnecessary overlays ## Implementation | File | Change | LoC | |------|--------|-----| | `src/common/constants/storage.ts` | Add `getSplashDismissedKey()` | +8 | | `src/browser/components/splashScreens/index.ts` | Registry of splash configs | +14 | | `src/browser/components/splashScreens/SplashScreenProvider.tsx` | Queue manager | +37 | | `src/browser/components/splashScreens/SplashScreen.tsx` | Generic dialog wrapper | +50 | | `src/browser/components/splashScreens/muxGateway.tsx` | Mux Gateway content | +35 | | `src/browser/App.tsx` | Mount provider | +3 | **Total: 149 lines added** ## Adding Future Splash Screens Just two steps: 1. Create component in `splashScreens/newFeature.tsx` 2. Add to registry: `{ id: "new-feature", priority: 2, component: NewFeatureSplash }` The queue manager handles everything else automatically. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-1` • Thinking: `high`_
1 parent 94f96bd commit a92f3e8

File tree

11 files changed

+249
-7
lines changed

11 files changed

+249
-7
lines changed

src/browser/App.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { AuthTokenModal } from "@/browser/components/AuthTokenModal";
4646

4747
import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
4848
import { SettingsModal } from "./components/Settings/SettingsModal";
49+
import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider";
4950
import { TutorialProvider } from "./contexts/TutorialContext";
5051
import { TooltipProvider } from "./components/ui/tooltip";
5152
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
@@ -719,11 +720,13 @@ function App() {
719720
<ExperimentsProvider>
720721
<TooltipProvider delayDuration={200}>
721722
<SettingsProvider>
722-
<TutorialProvider>
723-
<CommandRegistryProvider>
724-
<AppInner />
725-
</CommandRegistryProvider>
726-
</TutorialProvider>
723+
<SplashScreenProvider>
724+
<TutorialProvider>
725+
<CommandRegistryProvider>
726+
<AppInner />
727+
</CommandRegistryProvider>
728+
</TutorialProvider>
729+
</SplashScreenProvider>
727730
</SettingsProvider>
728731
</TooltipProvider>
729732
</ExperimentsProvider>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from "react";
2+
import { SplashScreen } from "./SplashScreen";
3+
import { useSettings } from "@/browser/contexts/SettingsContext";
4+
5+
export function MuxGatewaySplash({ onDismiss }: { onDismiss: () => void }) {
6+
const { open: openSettings } = useSettings();
7+
8+
const handleOpenSettings = () => {
9+
openSettings("providers");
10+
};
11+
12+
return (
13+
<SplashScreen
14+
title="Introducing Mux Gateway"
15+
onDismiss={onDismiss}
16+
primaryAction={{ label: "Open Settings", onClick: handleOpenSettings }}
17+
>
18+
<div className="text-muted" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
19+
<p>Mux Gateway gives you access to AI models through a unified API.</p>
20+
<p>
21+
If you haven&apos;t redeemed your Mux voucher yet,{" "}
22+
<a
23+
href="https://gateway.mux.coder.com/"
24+
target="_blank"
25+
rel="noopener noreferrer"
26+
className="text-accent hover:underline"
27+
>
28+
claim it here
29+
</a>
30+
.
31+
</p>
32+
<p>Once redeemed, add your coupon code in Settings → Providers → Mux Gateway.</p>
33+
</div>
34+
</SplashScreen>
35+
);
36+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from "react";
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogHeader,
6+
DialogTitle,
7+
DialogFooter,
8+
} from "@/browser/components/ui/dialog";
9+
import { Button } from "@/browser/components/ui/button";
10+
11+
interface SplashScreenProps {
12+
title: string;
13+
children: React.ReactNode;
14+
onDismiss: () => void;
15+
primaryAction?: {
16+
label: string;
17+
onClick: () => void;
18+
};
19+
dismissLabel?: string; // defaults to "Got it"
20+
}
21+
22+
export function SplashScreen(props: SplashScreenProps) {
23+
const handlePrimaryAction = () => {
24+
if (props.primaryAction) {
25+
props.primaryAction.onClick();
26+
}
27+
props.onDismiss();
28+
};
29+
30+
return (
31+
<Dialog open onOpenChange={(open) => !open && props.onDismiss()}>
32+
<DialogContent maxWidth="500px">
33+
<DialogHeader>
34+
<DialogTitle>{props.title}</DialogTitle>
35+
</DialogHeader>
36+
{props.children}
37+
<DialogFooter>
38+
{props.primaryAction && (
39+
<Button onClick={handlePrimaryAction}>{props.primaryAction.label}</Button>
40+
)}
41+
<Button variant="secondary" onClick={props.onDismiss}>
42+
{props.dismissLabel ?? "Got it"}
43+
</Button>
44+
</DialogFooter>
45+
</DialogContent>
46+
</Dialog>
47+
);
48+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
2+
import { SPLASH_REGISTRY, DISABLE_SPLASH_SCREENS, type SplashConfig } from "./index";
3+
import { useAPI } from "@/browser/contexts/API";
4+
5+
export function SplashScreenProvider({ children }: { children: ReactNode }) {
6+
const { api } = useAPI();
7+
const [queue, setQueue] = useState<SplashConfig[]>([]);
8+
const [loaded, setLoaded] = useState(false);
9+
10+
// Load viewed splash screens from config on mount
11+
useEffect(() => {
12+
// Skip if disabled or API not ready
13+
if (DISABLE_SPLASH_SCREENS || !api) {
14+
setLoaded(true);
15+
return;
16+
}
17+
18+
void (async () => {
19+
try {
20+
const viewedIds = await api.splashScreens.getViewedSplashScreens();
21+
22+
// Filter registry to undismissed splashes, sorted by priority (highest number first)
23+
const activeQueue = SPLASH_REGISTRY.filter((splash) => {
24+
// Priority 0 = never show
25+
if (splash.priority === 0) return false;
26+
27+
// Check if this splash has been viewed
28+
return !viewedIds.includes(splash.id);
29+
}).sort((a, b) => b.priority - a.priority); // Higher number = higher priority = shown first
30+
31+
setQueue(activeQueue);
32+
} catch (error) {
33+
console.error("Failed to load viewed splash screens:", error);
34+
// On error, don't show any splash screens
35+
setQueue([]);
36+
} finally {
37+
setLoaded(true);
38+
}
39+
})();
40+
}, [api]);
41+
42+
const currentSplash = queue[0] ?? null;
43+
44+
const dismiss = useCallback(async () => {
45+
if (!currentSplash || !api) return;
46+
47+
// Mark as viewed in config
48+
try {
49+
await api.splashScreens.markSplashScreenViewed({ splashId: currentSplash.id });
50+
} catch (error) {
51+
console.error("Failed to mark splash screen as viewed:", error);
52+
}
53+
54+
// Remove from queue, next one shows automatically
55+
setQueue((q) => q.slice(1));
56+
}, [currentSplash, api]);
57+
58+
// Don't render splash until we've loaded the viewed state
59+
if (!loaded) {
60+
return <>{children}</>;
61+
}
62+
63+
return (
64+
<>
65+
{children}
66+
{currentSplash && <currentSplash.component onDismiss={() => void dismiss()} />}
67+
</>
68+
);
69+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MuxGatewaySplash } from "./MuxGateway";
2+
3+
export interface SplashConfig {
4+
id: string;
5+
priority: number;
6+
component: React.FC<{ onDismiss: () => void }>;
7+
}
8+
9+
// Add new splash screens here
10+
// Priority 0 = Never show
11+
// Priority 1 = Lowest priority
12+
// Priority 2 = Medium priority
13+
// Priority 3+ = Higher priority (shown first)
14+
export const SPLASH_REGISTRY: SplashConfig[] = [
15+
{ id: "mux-gateway-intro", priority: 3, component: MuxGatewaySplash },
16+
// Future: { id: "new-feature-xyz", priority: 2, component: NewFeatureSplash },
17+
];
18+
19+
// Set to true to disable all splash screens (useful for testing)
20+
export const DISABLE_SPLASH_SCREENS = true;

src/common/orpc/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export {
110110
providers,
111111
ProvidersConfigMapSchema,
112112
server,
113+
splashScreens,
113114
telemetry,
114115
TelemetryEventSchema,
115116
terminal,

src/common/orpc/schemas/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,20 @@ export const server = {
502502
},
503503
};
504504

505+
// Splash screens
506+
export const splashScreens = {
507+
getViewedSplashScreens: {
508+
input: z.void(),
509+
output: z.array(z.string()),
510+
},
511+
markSplashScreenViewed: {
512+
input: z.object({
513+
splashId: z.string(),
514+
}),
515+
output: z.void(),
516+
},
517+
};
518+
505519
// Update
506520
export const update = {
507521
check: {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from "zod";
2+
3+
export const getViewedSplashScreens = {
4+
input: z.undefined(),
5+
output: z.array(z.string()),
6+
};
7+
8+
export const markSplashScreenViewed = {
9+
input: z.object({
10+
splashId: z.string(),
11+
}),
12+
output: z.undefined(),
13+
};

src/common/types/project.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export interface ProjectsConfig {
1414
projects: Map<string, ProjectConfig>;
1515
/** SSH hostname/alias for this machine (used for editor deep links in browser mode) */
1616
serverSshHost?: string;
17+
/** IDs of splash screens that have been viewed */
18+
viewedSplashScreens?: string[];
1719
}

src/node/config.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ export class Config {
5353
try {
5454
if (fs.existsSync(this.configFile)) {
5555
const data = fs.readFileSync(this.configFile, "utf-8");
56-
const parsed = JSON.parse(data) as { projects?: unknown; serverSshHost?: string };
56+
const parsed = JSON.parse(data) as {
57+
projects?: unknown;
58+
serverSshHost?: string;
59+
viewedSplashScreens?: string[];
60+
};
5761

5862
// Config is stored as array of [path, config] pairs
5963
if (parsed.projects && Array.isArray(parsed.projects)) {
@@ -67,6 +71,7 @@ export class Config {
6771
return {
6872
projects: projectsMap,
6973
serverSshHost: parsed.serverSshHost,
74+
viewedSplashScreens: parsed.viewedSplashScreens,
7075
};
7176
}
7277
}
@@ -86,12 +91,19 @@ export class Config {
8691
fs.mkdirSync(this.rootDir, { recursive: true });
8792
}
8893

89-
const data: { projects: Array<[string, ProjectConfig]>; serverSshHost?: string } = {
94+
const data: {
95+
projects: Array<[string, ProjectConfig]>;
96+
serverSshHost?: string;
97+
viewedSplashScreens?: string[];
98+
} = {
9099
projects: Array.from(config.projects.entries()),
91100
};
92101
if (config.serverSshHost) {
93102
data.serverSshHost = config.serverSshHost;
94103
}
104+
if (config.viewedSplashScreens) {
105+
data.viewedSplashScreens = config.viewedSplashScreens;
106+
}
95107

96108
await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8");
97109
} catch (error) {

0 commit comments

Comments
 (0)