diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index c0715cc940..052bede3f1 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import {
type Component,
+ createEffect,
createMemo,
createResource,
createSignal,
@@ -161,7 +162,7 @@ const effectMinDuration =
(e: Effect.Effect) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
-function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
+function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; onReady?: () => void }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -189,6 +190,16 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
),
)
+ let sent = false
+
+ createEffect(() => {
+ if (sent) return
+ const ready = checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"
+ if (!ready) return
+ sent = true
+ props.onReady?.()
+ })
+
return (
router?: Component
disableHealthCheck?: boolean
+ onReady?: () => void
}) {
return (
-
+
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index 6903d5ed20..04bbf44310 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -33,7 +33,6 @@ export default defineConfig({
rollupOptions: {
input: {
main: "src/renderer/index.html",
- loading: "src/renderer/loading.html",
},
},
},
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index b635caa4e4..489e03c175 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -41,8 +41,11 @@ const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
+let splash: BrowserWindow | null = null
+let ready = false
let sidecar: CommandChild | null = null
const loadingComplete = defer()
+const mainReady = defer()
const pendingDeepLinks: string[] = []
@@ -112,6 +115,11 @@ function emitDeepLinks(urls: string[]) {
}
function focusMainWindow() {
+ if (!ready) {
+ splash?.show()
+ splash?.focus()
+ return
+ }
if (!mainWindow) return
mainWindow.show()
mainWindow.focus()
@@ -121,12 +129,14 @@ function setInitStep(step: InitStep) {
initStep = step
logger.log("init step", { step })
initEmitter.emit("step", step)
+ BrowserWindow.getAllWindows().forEach((win) => {
+ win.webContents.send("init-step", step)
+ })
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer() : undefined
- let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
@@ -147,7 +157,7 @@ async function initialize() {
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
- if (overlay) sendSqliteMigrationProgress(overlay, progress)
+ if (splash) sendSqliteMigrationProgress(splash, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
@@ -173,25 +183,31 @@ async function initialize() {
deepLinks: pendingDeepLinks,
}
- if (needsMigration) {
- const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
- if (show) {
- overlay = createLoadingWindow(globals)
- await delay(1_000)
- }
- }
+ const startup = (async () => {
+ await loadingTask
+ setInitStep({ phase: "app_waiting" })
+ mainWindow = createMainWindow(globals, { show: false })
- await loadingTask
+ const ok = await Promise.race([mainReady.promise.then(() => true), delay(15_000).then(() => false)])
+ if (!ok) logger.warn("main window ready timed out")
+ })()
+
+ splash = createLoadingWindow(globals)
+
+ await startup
setInitStep({ phase: "done" })
- if (overlay) {
- await loadingComplete.promise
+ if (splash) {
+ const ok = await Promise.race([loadingComplete.promise.then(() => true), delay(2_000).then(() => false)])
+ if (!ok) logger.warn("loading window complete timed out")
+ splash.close()
+ splash = null
}
- mainWindow = createMainWindow(globals)
wireMenu()
-
- overlay?.close()
+ ready = true
+ mainWindow?.show()
+ mainWindow?.focus()
}
function wireMenu() {
@@ -240,6 +256,7 @@ registerIpcHandlers({
wslPath: async (path, mode) => wslPath(path, mode),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
+ mainWindowReady: () => mainReady.resolve(),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index d2cfc25241..ff4a84c328 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -26,6 +26,7 @@ type Deps = {
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise
resolveAppPath: (appName: string) => Promise
loadingWindowComplete: () => void
+ mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise | void
@@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
+ ipcMain.on("main-window-ready", () => deps.mainWindowReady())
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
index 170cd877cd..c1654a1457 100644
--- a/packages/desktop-electron/src/main/windows.ts
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -21,6 +21,10 @@ export function getBackgroundColor(): string | undefined {
return backgroundColor
}
+function back(mode = tone()) {
+ return backgroundColor ?? (mode === "dark" ? "#101010" : "#f8f8f8")
+}
+
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
@@ -54,7 +58,7 @@ export function setDockIcon() {
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
-export function createMainWindow(globals: Globals) {
+export function createMainWindow(globals: Globals, opts: { show?: boolean } = {}) {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
@@ -66,10 +70,10 @@ export function createMainWindow(globals: Globals) {
y: state.y,
width: state.width,
height: state.height,
- show: true,
+ show: opts.show ?? true,
title: "OpenCode",
icon: iconPath(),
- backgroundColor,
+ backgroundColor: back(mode),
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -104,29 +108,235 @@ export function createLoadingWindow(globals: Globals) {
height: 480,
resizable: false,
center: true,
- show: true,
+ show: false,
+ frame: false,
icon: iconPath(),
- backgroundColor,
- ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
- ...(process.platform === "win32"
- ? {
- frame: false,
- titleBarStyle: "hidden" as const,
- titleBarOverlay: overlay({ mode }),
- }
- : {}),
+ backgroundColor: back(mode),
webPreferences: {
preload: join(root, "../preload/index.mjs"),
sandbox: false,
},
})
- loadWindow(win, "loading.html")
+ win.once("ready-to-show", () => {
+ if (!win.isDestroyed()) win.show()
+ })
+
+ loadSplash(win, mode)
injectGlobals(win, globals)
return win
}
+function loadSplash(win: BrowserWindow, mode: "dark" | "light") {
+ void win.loadURL(`data:text/html;charset=UTF-8,${encodeURIComponent(page(mode))}`)
+}
+
+function page(mode: "dark" | "light") {
+ const dark = mode === "dark"
+ const bg = back(mode)
+ const base = dark ? "#7e7e7e" : "#8f8f8f"
+ const weak = dark ? "#343434" : "#dbdbdb"
+ const strong = dark ? "#ededed" : "#171717"
+ const track = dark ? "rgba(255,255,255,0.078)" : "rgba(0,0,0,0.051)"
+ const warn = dark ? "#fbb73c" : "#ebb76e"
+ const pulse = mark(base, strong)
+ const splash = mark(weak, strong)
+
+ return `
+
+
+
+
+ OpenCode
+
+
+
+
+
+
+`
+}
+
+function mark(base: string, strong: string) {
+ return ``
+}
+
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index 296fcb2f1c..ff6e077388 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -11,6 +11,11 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
+ onInitStep: (cb) => {
+ const handler = (_: unknown, step: InitStep) => cb(step)
+ ipcRenderer.on("init-step", handler)
+ return () => ipcRenderer.removeListener("init-step", handler)
+ },
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
@@ -60,6 +65,7 @@ const api: ElectronAPI = {
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
+ mainWindowReady: () => ipcRenderer.send("main-window-ready"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index f8e6d52c7d..95b1bac17c 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -1,4 +1,8 @@
-export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
+export type InitStep =
+ | { phase: "server_waiting" }
+ | { phase: "sqlite_waiting" }
+ | { phase: "app_waiting" }
+ | { phase: "done" }
export type ServerReadyData = {
url: string
@@ -19,6 +23,7 @@ export type ElectronAPI = {
killSidecar: () => Promise
installCli: () => Promise
awaitInitialization: (onStep: (step: InitStep) => void) => Promise
+ onInitStep: (cb: (step: InitStep) => void) => () => void
getDefaultServerUrl: () => Promise
setDefaultServerUrl: (url: string | null) => Promise
getWslConfig: () => Promise
@@ -66,6 +71,7 @@ export type ElectronAPI = {
setZoomFactor: (factor: number) => Promise
setTitlebar: (theme: TitlebarTheme) => Promise
loadingWindowComplete: () => void
+ mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise
diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts
index bd8281c2fb..e7118ba74d 100644
--- a/packages/desktop-electron/src/renderer/html.test.ts
+++ b/packages/desktop-electron/src/renderer/html.test.ts
@@ -16,7 +16,7 @@ const html = async (name: string) => Bun.file(join(dir, name)).text()
* All local resource references must use relative paths (`./`).
*/
describe("electron renderer html", () => {
- for (const name of ["index.html", "loading.html"]) {
+ for (const name of ["index.html"]) {
describe(name, () => {
test("script src attributes use relative paths", async () => {
const content = await html(name)
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 44f2e6360c..068956cc5a 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -332,6 +332,7 @@ render(() => {
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
+ onReady={() => window.api.mainWindowReady()}
>
diff --git a/packages/desktop-electron/src/renderer/loading.html b/packages/desktop-electron/src/renderer/loading.html
deleted file mode 100644
index ae3725af61..0000000000
--- a/packages/desktop-electron/src/renderer/loading.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
- OpenCode
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/desktop-electron/src/renderer/loading.tsx b/packages/desktop-electron/src/renderer/loading.tsx
deleted file mode 100644
index 000057e0a8..0000000000
--- a/packages/desktop-electron/src/renderer/loading.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { MetaProvider } from "@solidjs/meta"
-import { render } from "solid-js/web"
-import "@opencode-ai/app/index.css"
-import { Font } from "@opencode-ai/ui/font"
-import { Splash } from "@opencode-ai/ui/logo"
-import { Progress } from "@opencode-ai/ui/progress"
-import "./styles.css"
-import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
-import type { InitStep, SqliteMigrationProgress } from "../preload/types"
-
-const root = document.getElementById("root")!
-const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
-const delays = [3000, 9000]
-
-render(() => {
- const [step, setStep] = createSignal(null)
- const [line, setLine] = createSignal(0)
- const [percent, setPercent] = createSignal(0)
-
- const phase = createMemo(() => step()?.phase)
-
- const value = createMemo(() => {
- if (phase() === "done") return 100
- return Math.max(25, Math.min(100, percent()))
- })
-
- window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
-
- onMount(() => {
- setLine(0)
- setPercent(0)
-
- const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
-
- const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
- if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
- if (progress.type === "Done") {
- setPercent(100)
- setStep({ phase: "done" })
- }
- })
-
- onCleanup(() => {
- listener()
- timers.forEach(clearTimeout)
- })
- })
-
- createEffect(() => {
- if (phase() !== "done") return
-
- const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
- onCleanup(() => clearTimeout(timer))
- })
-
- const status = createMemo(() => {
- if (phase() === "done") return "All done"
- if (phase() === "sqlite_waiting") return lines[line()]
- return "Just a moment..."
- })
-
- return (
-
-
-
-
-
-
-
- {status()}
-
-
-
-
-
- )
-}, root)