From 84df96eaefe7a920b48b205501fc849ba027e9ad Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 13 Mar 2026 09:18:27 +0800 Subject: [PATCH] desktop: multi-window support in electron (#17155) --- packages/app/src/app.tsx | 35 ++++++++++--------- packages/app/src/entry.tsx | 7 ++-- packages/desktop-electron/src/main/index.ts | 9 +++-- packages/desktop-electron/src/main/ipc.ts | 4 +++ packages/desktop-electron/src/main/menu.ts | 6 ++++ packages/desktop-electron/src/main/windows.ts | 14 ++++++-- .../desktop-electron/src/preload/index.ts | 2 ++ .../desktop-electron/src/preload/types.ts | 2 ++ .../desktop-electron/src/renderer/index.tsx | 26 +++++++++++--- packages/desktop/src/menu.ts | 7 ++-- packages/ui/src/theme/context.tsx | 16 ++++++--- 11 files changed, 91 insertions(+), 37 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 1b7ffde46..c6fca36d5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -159,7 +159,7 @@ const effectMinDuration = (e: Effect.Effect) => Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) -function ConnectionGate(props: ParentProps) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) { // performs repeated health check with a grace period for // non-http connections, otherwise fails instantly const [startupHealthCheck, healthCheckActions] = createResource(() => - Effect.gen(function* () { - if (!server.current) return true - const { http, type } = server.current + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current - while (true) { - const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true - if (checkMode() === "background" || type === "http") return false - } - }).pipe( - effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0), - Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }), - Effect.ensuring(Effect.sync(() => setCheckMode("background"))), - Effect.runPromise, - ), + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0), + Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), ) return ( @@ -261,10 +263,11 @@ export function AppInterface(props: { defaultServer: ServerConnection.Key servers?: Array router?: Component + disableHealthCheck?: boolean }) { return ( - + ( - + ), diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 64c2eb10f..484e4feb2 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -5,7 +5,7 @@ import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" import type { Event } from "electron" -import { app, type BrowserWindow, dialog } from "electron" +import { app, BrowserWindow, dialog } from "electron" import pkg from "electron-updater" const APP_NAMES: Record = { @@ -32,7 +32,7 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" -import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows" +import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } @@ -156,12 +156,9 @@ async function initialize() { const globals = { updaterEnabled: UPDATER_ENABLED, - wsl: getWslConfig().enabled, deepLinks: pendingDeepLinks, } - wireMenu() - if (needsMigration) { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { @@ -178,6 +175,7 @@ async function initialize() { } mainWindow = createMainWindow(globals) + wireMenu() overlay?.close() } @@ -231,6 +229,7 @@ registerIpcHandlers({ runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), checkUpdate: async () => checkUpdate(), installUpdate: async () => installUpdate(), + setBackgroundColor: (color) => setBackgroundColor(color), }) function killSidecar() { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index c0d8773c2..71b3c3395 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -24,6 +24,7 @@ type Deps = { runUpdater: (alertOnFail: boolean) => Promise | void checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise | void + setBackgroundColor: (color: string) => void } export function registerIpcHandlers(deps: Deps) { @@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) ipcMain.handle("check-update", () => deps.checkUpdate()) ipcMain.handle("install-update", () => deps.installUpdate()) + ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) @@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) { new Notification({ title, body }).show() }) + ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length) + ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 53707ba7f..d8997be31 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -1,6 +1,7 @@ import { BrowserWindow, Menu, shell } from "electron" import { UPDATER_ENABLED } from "./constants" +import { createMainWindow } from "./windows" type Deps = { trigger: (id: string) => void @@ -48,6 +49,11 @@ export function createMenu(deps: Deps) { submenu: [ { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") }, { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") }, + { + label: "New Window", + accelerator: "Cmd+Shift+N", + click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }), + }, { type: "separator" }, { role: "close" }, ], diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index d4ec5ac79..0b7783f28 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types" type Globals = { updaterEnabled: boolean - wsl: boolean deepLinks?: string[] } const root = dirname(fileURLToPath(import.meta.url)) +let backgroundColor: string | undefined + +export function setBackgroundColor(color: string) { + backgroundColor = color +} + +export function getBackgroundColor(): string | undefined { + return backgroundColor +} + function iconsDir() { return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons") } @@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) { show: true, title: "OpenCode", icon: iconPath(), + backgroundColor, ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, @@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) { center: true, show: true, icon: iconPath(), + backgroundColor, ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}), ...(process.platform === "win32" ? { @@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) { const deepLinks = globals.deepLinks ?? [] const data = { updaterEnabled: globals.updaterEnabled, - wsl: globals.wsl, deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks, } void win.webContents.executeJavaScript( diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index c1ed9afd2..296fcb2f1 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -28,6 +28,7 @@ const api: ElectronAPI = { storeKeys: (name) => ipcRenderer.invoke("store-keys", name), storeLength: (name) => ipcRenderer.invoke("store-length", name), + getWindowCount: () => ipcRenderer.invoke("get-window-count"), onSqliteMigrationProgress: (cb) => { const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress) ipcRenderer.on("sqlite-migration-progress", handler) @@ -62,6 +63,7 @@ const api: ElectronAPI = { runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), checkUpdate: () => ipcRenderer.invoke("check-update"), installUpdate: () => ipcRenderer.invoke("install-update"), + setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ae4ca213d..100508fcd 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -36,6 +36,7 @@ export type ElectronAPI = { storeKeys: (name: string) => Promise storeLength: (name: string) => Promise + getWindowCount: () => Promise onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void onMenuCommand: (cb: (id: string) => void) => () => void onDeepLink: (cb: (urls: string[]) => void) => () => void @@ -66,4 +67,5 @@ export type ElectronAPI = { runUpdater: (alertOnFail: boolean) => Promise checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise + setBackgroundColor: (color: string) => Promise } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index e313d5594..6719c04be 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -10,14 +10,15 @@ import { useCommand, } from "@opencode-ai/app" import type { AsyncStorage } from "@solid-primitives/storage" -import { createResource, onCleanup, onMount, Show } from "solid-js" -import { render } from "solid-js/web" import { MemoryRouter } from "@solidjs/router" +import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" +import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" +import { useTheme } from "@opencode-ai/ui/theme" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -226,7 +227,9 @@ const createPlatform = (): Platform => { const image = await window.api.readClipboardImage().catch(() => null) if (!image) return null const blob = new Blob([image.buffer], { type: "image/png" }) - return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }) + return new File([blob], `pasted-image-${Date.now()}.png`, { + type: "image/png", + }) }, } } @@ -240,6 +243,8 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const [windowCount] = createResource(() => window.api.getWindowCount()) + // Fetch sidecar credentials (available immediately, before health check) const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) @@ -276,6 +281,18 @@ render(() => { function Inner() { const cmd = useCommand() menuTrigger = (id) => cmd.trigger(id) + + const theme = useTheme() + + createEffect(() => { + theme.themeId() + theme.mode() + const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim() + if (bg) { + void window.api.setBackgroundColor(bg) + } + }) + return null } @@ -289,13 +306,14 @@ render(() => { return ( - + {(_) => { return ( 1} > diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index de6a1d6a7..9005dd702 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,12 +1,11 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" +import { openUrl } from "@tauri-apps/plugin-opener" import { type as ostype } from "@tauri-apps/plugin-os" import { relaunch } from "@tauri-apps/plugin-process" -import { openUrl } from "@tauri-apps/plugin-opener" - -import { runUpdater, UPDATER_ENABLED } from "./updater" +import { commands } from "./bindings" import { installCli } from "./cli" import { initI18n, t } from "./i18n" -import { commands } from "./bindings" +import { runUpdater, UPDATER_ENABLED } from "./updater" export async function createMenu(trigger: (id: string) => void) { if (ostype() !== "macos") return diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index ad82a088d..9808c8e84 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,9 +1,9 @@ -import { onMount, onCleanup, createEffect } from "solid-js" +import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -import type { DesktopTheme } from "./types" -import { resolveThemeVariant, themeToCss } from "./resolve" -import { DEFAULT_THEMES } from "./default-themes" import { createSimpleContext } from "../context/helper" +import { DEFAULT_THEMES } from "./default-themes" +import { resolveThemeVariant, themeToCss } from "./resolve" +import type { DesktopTheme } from "./types" export type ColorScheme = "light" | "dark" | "system" @@ -87,6 +87,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewScheme: null as ColorScheme | null, }) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) + } + }) + onMount(() => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") const handler = () => {