Skip to content

Commit 1ff76bb

Browse files
committed
🤖 Add splash screen for instant startup feedback
## Problem Users see dock icon but no window for 6-13s during app startup (varies by machine). Even with lazy-loaded services (#223), the renderer makes IPC calls on mount which requires services to be loaded first, so the main window can't appear until everything is ready. ## Solution Added native splash screen that appears instantly (<100ms) while services load. ## Implementation **Three-phase startup:** 1. **Show splash** - Native BrowserWindow with static HTML (<100ms) - No React, no IPC, no heavy dependencies - Matches app theme colors from colors.tsx - Shows "Loading services..." with spinner 2. **Load services** - Happens while splash is visible (~6-13s) - Config, IpcMain, AI SDK, tokenizer modules - User gets instant visual feedback 3. **Show main window** - Close splash, reveal app - Services guaranteed ready when window appears - Main window uses "ready-to-show" event to avoid white flash ## Changes - **Added `static/splash.html`** - Lightweight loading screen matching app theme - **Modified `src/main.ts`** - Three-phase startup with splash screen - **Updated `Makefile`** - Copy splash.html to dist during build ## Benefits - ✅ Instant visual feedback (<100ms vs 6-13s black screen) - ✅ No user confusion ("is it broken?") - ✅ Services guaranteed ready (no race conditions) - ✅ Clean transition to main window - ✅ All tests pass (410 tests) _Generated with `cmux`_
1 parent fa61ff6 commit 1ff76bb

File tree

3 files changed

+141
-3
lines changed

3 files changed

+141
-3
lines changed

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
include fmt.mk
2323

2424
.PHONY: all build dev start clean help
25-
.PHONY: build-renderer version build-icons
25+
.PHONY: build-renderer version build-icons build-static
2626
.PHONY: lint lint-fix typecheck static-check
2727
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e
2828
.PHONY: dist dist-mac dist-win dist-linux
@@ -62,7 +62,7 @@ start: node_modules/.installed build-main build-preload ## Build and start Elect
6262
@bun x electron --remote-debugging-port=9222 .
6363

6464
## Build targets (can run in parallel)
65-
build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons ## Build all targets
65+
build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static ## Build all targets
6666

6767
build-main: node_modules/.installed dist/main.js ## Build main process
6868

@@ -86,6 +86,10 @@ build-renderer: node_modules/.installed src/version.ts ## Build renderer process
8686
@echo "Building renderer..."
8787
@bun x vite build
8888

89+
build-static: ## Copy static assets to dist
90+
@echo "Copying static assets..."
91+
@cp static/splash.html dist/splash.html
92+
8993
# Always regenerate version file (marked as .PHONY above)
9094
version: ## Generate version file
9195
@./scripts/generate-version.sh

src/main.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ if (!gotTheLock) {
126126
}
127127

128128
let mainWindow: BrowserWindow | null = null;
129+
let splashWindow: BrowserWindow | null = null;
129130

130131
function createMenu() {
131132
const template: MenuItemConstructorOptions[] = [
@@ -182,6 +183,44 @@ function createMenu() {
182183
Menu.setApplicationMenu(menu);
183184
}
184185

186+
/**
187+
* Create and show splash screen - instant visual feedback (<100ms)
188+
*
189+
* Shows a lightweight native window with static HTML while services load.
190+
* No IPC, no React, no heavy dependencies - just immediate user feedback.
191+
*/
192+
function showSplashScreen() {
193+
splashWindow = new BrowserWindow({
194+
width: 400,
195+
height: 300,
196+
frame: false,
197+
transparent: true,
198+
alwaysOnTop: true,
199+
center: true,
200+
resizable: false,
201+
webPreferences: {
202+
nodeIntegration: false,
203+
contextIsolation: true,
204+
},
205+
});
206+
207+
void splashWindow.loadFile(path.join(__dirname, "splash.html"));
208+
209+
splashWindow.on("closed", () => {
210+
splashWindow = null;
211+
});
212+
}
213+
214+
/**
215+
* Close splash screen
216+
*/
217+
function closeSplashScreen() {
218+
if (splashWindow) {
219+
splashWindow.close();
220+
splashWindow = null;
221+
}
222+
}
223+
185224
async function createWindow() {
186225
// Lazy-load Config and IpcMain only when window is created
187226
// This defers loading heavy AI SDK dependencies until actually needed
@@ -218,11 +257,17 @@ async function createWindow() {
218257
// Hide menu bar on Linux by default (like VS Code)
219258
// User can press Alt to toggle it
220259
autoHideMenuBar: process.platform === "linux",
260+
show: false, // Don't show until ready-to-show event
221261
});
222262

223263
// Register IPC handlers with the main window
224264
ipcMain.register(electronIpcMain, mainWindow);
225265

266+
// Show window once it's ready to avoid white flash
267+
mainWindow.once("ready-to-show", () => {
268+
mainWindow?.show();
269+
});
270+
226271
// Open all external links in default browser
227272
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
228273
void shell.openExternal(url);
@@ -278,7 +323,12 @@ if (gotTheLock) {
278323
}
279324

280325
createMenu();
326+
327+
// Show splash screen immediately, then load services, then show main window
328+
// This gives instant visual feedback (<100ms) while services load (~6-13s)
329+
showSplashScreen();
281330
await createWindow();
331+
closeSplashScreen();
282332

283333
// Start loading tokenizer modules in background after window is created
284334
// This ensures accurate token counts for first API calls (especially in e2e tests)
@@ -301,7 +351,10 @@ if (gotTheLock) {
301351
// Only create window if app is ready and no window exists
302352
// This prevents "Cannot create BrowserWindow before app is ready" error
303353
if (app.isReady() && mainWindow === null) {
304-
void createWindow();
354+
showSplashScreen();
355+
void createWindow().then(() => {
356+
closeSplashScreen();
357+
});
305358
}
306359
});
307360
}

static/splash.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>cmux - Loading</title>
7+
<style>
8+
* {
9+
margin: 0;
10+
padding: 0;
11+
box-sizing: border-box;
12+
}
13+
14+
body {
15+
width: 400px;
16+
height: 300px;
17+
/* Match --color-background (hsl(0 0% 12%)) */
18+
background: hsl(0 0% 12%);
19+
display: flex;
20+
flex-direction: column;
21+
align-items: center;
22+
justify-content: center;
23+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
24+
/* Match --color-text (hsl(0 0% 83%)) */
25+
color: hsl(0 0% 83%);
26+
overflow: hidden;
27+
border-radius: 12px;
28+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
29+
}
30+
31+
.logo {
32+
font-size: 48px;
33+
font-weight: 700;
34+
letter-spacing: -2px;
35+
margin-bottom: 24px;
36+
/* Gradient from plan mode blue (hsl(210 70% 40%)) to exec mode purple (hsl(268.56deg 94.04% 55.19%)) */
37+
background: linear-gradient(135deg, hsl(210 70% 45%) 0%, hsl(268.56deg 94.04% 55.19%) 100%);
38+
-webkit-background-clip: text;
39+
-webkit-text-fill-color: transparent;
40+
background-clip: text;
41+
}
42+
43+
.loading-text {
44+
font-size: 16px;
45+
/* Match --color-text-secondary (hsl(0 0% 42%)) */
46+
color: hsl(0 0% 42%);
47+
margin-bottom: 16px;
48+
}
49+
50+
.spinner {
51+
width: 40px;
52+
height: 40px;
53+
/* Match --color-border (hsl(240 2% 25%)) */
54+
border: 3px solid hsl(240 2% 25%);
55+
/* Match --color-plan-mode (hsl(210 70% 40%)) */
56+
border-top-color: hsl(210 70% 40%);
57+
border-radius: 50%;
58+
animation: spin 0.8s linear infinite;
59+
}
60+
61+
@keyframes spin {
62+
to { transform: rotate(360deg); }
63+
}
64+
65+
.version {
66+
position: absolute;
67+
bottom: 20px;
68+
font-size: 11px;
69+
/* Match --color-text-secondary (hsl(0 0% 42%)) */
70+
color: hsl(0 0% 42%);
71+
}
72+
</style>
73+
</head>
74+
<body>
75+
<div class="logo">cmux</div>
76+
<div class="loading-text">Loading services...</div>
77+
<div class="spinner"></div>
78+
<div class="version">coder multiplexer</div>
79+
</body>
80+
</html>
81+

0 commit comments

Comments
 (0)