Skip to content

Commit e48dafa

Browse files
authored
🤖 Add splash screen for instant startup feedback (#226)
## Problem Users see dock icon but no window for 6-13s during app startup (varies by machine: ~6s on M3, ~13s on M1). 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) ## Testing Manually tested on M3 - splash appears instantly, main window appears ~6s later. _Generated with `cmux`_
1 parent fa61ff6 commit e48dafa

File tree

3 files changed

+221
-28
lines changed

3 files changed

+221
-28
lines changed

Makefile

Lines changed: 8 additions & 3 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
@@ -58,11 +58,11 @@ dev: node_modules/.installed build-main ## Start development server (Vite + Type
5858
"bun x concurrently \"bun x tsc -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
5959
"vite"
6060

61-
start: node_modules/.installed build-main build-preload ## Build and start Electron app
61+
start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app
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,11 @@ 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+
@mkdir -p dist
92+
@cp static/splash.html dist/splash.html
93+
8994
# Always regenerate version file (marked as .PHONY above)
9095
version: ## Generate version file
9196
@./scripts/generate-version.sh

src/main.ts

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ if (!gotTheLock) {
126126
}
127127

128128
let mainWindow: BrowserWindow | null = null;
129+
let splashWindow: BrowserWindow | null = null;
130+
131+
/**
132+
* Format timestamp as HH:MM:SS.mmm for readable logging
133+
*/
134+
function timestamp(): string {
135+
const now = new Date();
136+
const hours = String(now.getHours()).padStart(2, "0");
137+
const minutes = String(now.getMinutes()).padStart(2, "0");
138+
const seconds = String(now.getSeconds()).padStart(2, "0");
139+
const ms = String(now.getMilliseconds()).padStart(3, "0");
140+
return `${hours}:${minutes}:${seconds}.${ms}`;
141+
}
129142

130143
function createMenu() {
131144
const template: MenuItemConstructorOptions[] = [
@@ -182,28 +195,101 @@ function createMenu() {
182195
Menu.setApplicationMenu(menu);
183196
}
184197

185-
async function createWindow() {
186-
// Lazy-load Config and IpcMain only when window is created
187-
// This defers loading heavy AI SDK dependencies until actually needed
188-
if (!config || !ipcMain || !loadTokenizerModulesFn) {
189-
/* eslint-disable no-restricted-syntax */
190-
// Dynamic imports are justified here for performance:
191-
// - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.)
192-
// - These are large modules that would block app startup if loaded statically
193-
// - Loading happens once on first window creation, then cached
194-
const [
195-
{ Config: ConfigClass },
196-
{ IpcMain: IpcMainClass },
197-
{ loadTokenizerModules: loadTokenizerFn },
198-
] = await Promise.all([
199-
import("./config"),
200-
import("./services/ipcMain"),
201-
import("./utils/main/tokenizer"),
202-
]);
203-
/* eslint-enable no-restricted-syntax */
204-
config = new ConfigClass();
205-
ipcMain = new IpcMainClass(config);
206-
loadTokenizerModulesFn = loadTokenizerFn;
198+
/**
199+
* Create and show splash screen - instant visual feedback (<100ms)
200+
*
201+
* Shows a lightweight native window with static HTML while services load.
202+
* No IPC, no React, no heavy dependencies - just immediate user feedback.
203+
*/
204+
async function showSplashScreen() {
205+
const startTime = Date.now();
206+
console.log(`[${timestamp()}] Showing splash screen...`);
207+
208+
splashWindow = new BrowserWindow({
209+
width: 400,
210+
height: 300,
211+
frame: false,
212+
transparent: false,
213+
alwaysOnTop: true,
214+
center: true,
215+
resizable: false,
216+
show: false, // Don't show until HTML is loaded
217+
webPreferences: {
218+
nodeIntegration: false,
219+
contextIsolation: true,
220+
},
221+
});
222+
223+
// Wait for splash HTML to load
224+
await splashWindow.loadFile(path.join(__dirname, "splash.html"));
225+
226+
// Wait for the window to actually be shown and rendered before continuing
227+
// This ensures the splash is visible before we block the event loop with heavy work
228+
await new Promise<void>((resolve) => {
229+
splashWindow!.once("show", () => {
230+
const loadTime = Date.now() - startTime;
231+
console.log(`[${timestamp()}] Splash screen shown (${loadTime}ms)`);
232+
// Give one more event loop tick for the window to actually paint
233+
setImmediate(resolve);
234+
});
235+
splashWindow!.show();
236+
});
237+
238+
splashWindow.on("closed", () => {
239+
console.log(`[${timestamp()}] Splash screen closed event`);
240+
splashWindow = null;
241+
});
242+
}
243+
244+
/**
245+
* Close splash screen
246+
*/
247+
function closeSplashScreen() {
248+
if (splashWindow) {
249+
console.log(`[${timestamp()}] Closing splash screen...`);
250+
splashWindow.close();
251+
splashWindow = null;
252+
}
253+
}
254+
255+
/**
256+
* Load backend services (Config, IpcMain, AI SDK, tokenizer)
257+
*
258+
* Heavy initialization (~6-13s) happens here while splash is visible.
259+
* This is the slow part that delays app startup.
260+
*/
261+
async function loadServices(): Promise<void> {
262+
if (config && ipcMain && loadTokenizerModulesFn) return; // Already loaded
263+
264+
const startTime = Date.now();
265+
console.log(`[${timestamp()}] Loading services...`);
266+
267+
/* eslint-disable no-restricted-syntax */
268+
// Dynamic imports are justified here for performance:
269+
// - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.)
270+
// - These are large modules (~6-13s load time) that would block splash from appearing
271+
// - Loading happens once, then cached
272+
const [
273+
{ Config: ConfigClass },
274+
{ IpcMain: IpcMainClass },
275+
{ loadTokenizerModules: loadTokenizerFn },
276+
] = await Promise.all([
277+
import("./config"),
278+
import("./services/ipcMain"),
279+
import("./utils/main/tokenizer"),
280+
]);
281+
/* eslint-enable no-restricted-syntax */
282+
config = new ConfigClass();
283+
ipcMain = new IpcMainClass(config);
284+
loadTokenizerModulesFn = loadTokenizerFn;
285+
286+
const loadTime = Date.now() - startTime;
287+
console.log(`[${timestamp()}] Services loaded in ${loadTime}ms`);
288+
}
289+
290+
function createWindow() {
291+
if (!ipcMain) {
292+
throw new Error("Services must be loaded before creating window");
207293
}
208294

209295
mainWindow = new BrowserWindow({
@@ -218,11 +304,19 @@ async function createWindow() {
218304
// Hide menu bar on Linux by default (like VS Code)
219305
// User can press Alt to toggle it
220306
autoHideMenuBar: process.platform === "linux",
307+
show: false, // Don't show until ready-to-show event
221308
});
222309

223310
// Register IPC handlers with the main window
224311
ipcMain.register(electronIpcMain, mainWindow);
225312

313+
// Show window once it's ready and close splash
314+
mainWindow.once("ready-to-show", () => {
315+
console.log(`[${timestamp()}] Main window ready to show`);
316+
mainWindow?.show();
317+
closeSplashScreen();
318+
});
319+
226320
// Open all external links in default browser
227321
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
228322
void shell.openExternal(url);
@@ -278,14 +372,23 @@ if (gotTheLock) {
278372
}
279373

280374
createMenu();
281-
await createWindow();
375+
376+
// Three-phase startup:
377+
// 1. Show splash immediately (<100ms) and wait for it to load
378+
// 2. Load services while splash visible (fast - ~100ms)
379+
// 3. Create window and start loading content (splash stays visible)
380+
// 4. When window ready-to-show: close splash, show main window
381+
await showSplashScreen(); // Wait for splash to actually load
382+
await loadServices();
383+
createWindow();
384+
// Note: splash closes in ready-to-show event handler
282385

283386
// Start loading tokenizer modules in background after window is created
284387
// This ensures accurate token counts for first API calls (especially in e2e tests)
285388
// Loading happens asynchronously and won't block the UI
286389
if (loadTokenizerModulesFn) {
287390
void loadTokenizerModulesFn().then(() => {
288-
console.log("Tokenizer modules loaded");
391+
console.log(`[${timestamp()}] Tokenizer modules loaded`);
289392
});
290393
}
291394
// No need to auto-start workspaces anymore - they start on demand
@@ -301,7 +404,11 @@ if (gotTheLock) {
301404
// Only create window if app is ready and no window exists
302405
// This prevents "Cannot create BrowserWindow before app is ready" error
303406
if (app.isReady() && mainWindow === null) {
304-
void createWindow();
407+
void (async () => {
408+
await showSplashScreen();
409+
await loadServices();
410+
createWindow();
411+
})();
305412
}
306413
});
307414
}

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)