Skip to content

Commit 84df96e

Browse files
authored
desktop: multi-window support in electron (anomalyco#17155)
1 parent d9dd33a commit 84df96e

11 files changed

Lines changed: 91 additions & 37 deletions

File tree

packages/app/src/app.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ const effectMinDuration =
159159
<A, E, R>(e: Effect.Effect<A, E, R>) =>
160160
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
161161

162-
function ConnectionGate(props: ParentProps) {
162+
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
163163
const server = useServer()
164164
const checkServerHealth = useCheckServerHealth()
165165

@@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) {
168168
// performs repeated health check with a grace period for
169169
// non-http connections, otherwise fails instantly
170170
const [startupHealthCheck, healthCheckActions] = createResource(() =>
171-
Effect.gen(function* () {
172-
if (!server.current) return true
173-
const { http, type } = server.current
171+
props.disableHealthCheck
172+
? true
173+
: Effect.gen(function* () {
174+
if (!server.current) return true
175+
const { http, type } = server.current
174176

175-
while (true) {
176-
const res = yield* Effect.promise(() => checkServerHealth(http))
177-
if (res.healthy) return true
178-
if (checkMode() === "background" || type === "http") return false
179-
}
180-
}).pipe(
181-
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
182-
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
183-
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
184-
Effect.runPromise,
185-
),
177+
while (true) {
178+
const res = yield* Effect.promise(() => checkServerHealth(http))
179+
if (res.healthy) return true
180+
if (checkMode() === "background" || type === "http") return false
181+
}
182+
}).pipe(
183+
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
184+
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
185+
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
186+
Effect.runPromise,
187+
),
186188
)
187189

188190
return (
@@ -261,10 +263,11 @@ export function AppInterface(props: {
261263
defaultServer: ServerConnection.Key
262264
servers?: Array<ServerConnection.Any>
263265
router?: Component<BaseRouterProps>
266+
disableHealthCheck?: boolean
264267
}) {
265268
return (
266269
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
267-
<ConnectionGate>
270+
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
268271
<GlobalSDKProvider>
269272
<GlobalSyncProvider>
270273
<Dynamic

packages/app/src/entry.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// @refresh reload
22

3-
import { iife } from "@opencode-ai/util/iife"
43
import { render } from "solid-js/web"
54
import { AppBaseProviders, AppInterface } from "@/app"
65
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
132131
() => (
133132
<PlatformProvider value={platform}>
134133
<AppBaseProviders>
135-
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
134+
<AppInterface
135+
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
136+
servers={[server]}
137+
disableHealthCheck
138+
/>
136139
</AppBaseProviders>
137140
</PlatformProvider>
138141
),

packages/desktop-electron/src/main/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createServer } from "node:net"
55
import { homedir } from "node:os"
66
import { join } from "node:path"
77
import type { Event } from "electron"
8-
import { app, type BrowserWindow, dialog } from "electron"
8+
import { app, BrowserWindow, dialog } from "electron"
99
import pkg from "electron-updater"
1010

1111
const APP_NAMES: Record<string, string> = {
@@ -32,7 +32,7 @@ import { initLogging } from "./logging"
3232
import { parseMarkdown } from "./markdown"
3333
import { createMenu } from "./menu"
3434
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
35-
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
35+
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
3636

3737
const initEmitter = new EventEmitter()
3838
let initStep: InitStep = { phase: "server_waiting" }
@@ -156,12 +156,9 @@ async function initialize() {
156156

157157
const globals = {
158158
updaterEnabled: UPDATER_ENABLED,
159-
wsl: getWslConfig().enabled,
160159
deepLinks: pendingDeepLinks,
161160
}
162161

163-
wireMenu()
164-
165162
if (needsMigration) {
166163
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
167164
if (show) {
@@ -178,6 +175,7 @@ async function initialize() {
178175
}
179176

180177
mainWindow = createMainWindow(globals)
178+
wireMenu()
181179

182180
overlay?.close()
183181
}
@@ -231,6 +229,7 @@ registerIpcHandlers({
231229
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
232230
checkUpdate: async () => checkUpdate(),
233231
installUpdate: async () => installUpdate(),
232+
setBackgroundColor: (color) => setBackgroundColor(color),
234233
})
235234

236235
function killSidecar() {

packages/desktop-electron/src/main/ipc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Deps = {
2424
runUpdater: (alertOnFail: boolean) => Promise<void> | void
2525
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
2626
installUpdate: () => Promise<void> | void
27+
setBackgroundColor: (color: string) => void
2728
}
2829

2930
export function registerIpcHandlers(deps: Deps) {
@@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) {
5354
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
5455
ipcMain.handle("check-update", () => deps.checkUpdate())
5556
ipcMain.handle("install-update", () => deps.installUpdate())
57+
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
5658
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
5759
const store = getStore(name)
5860
const value = store.get(key)
@@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) {
140142
new Notification({ title, body }).show()
141143
})
142144

145+
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
146+
143147
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
144148
const win = BrowserWindow.fromWebContents(event.sender)
145149
return win?.isFocused() ?? false

packages/desktop-electron/src/main/menu.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BrowserWindow, Menu, shell } from "electron"
22

33
import { UPDATER_ENABLED } from "./constants"
4+
import { createMainWindow } from "./windows"
45

56
type Deps = {
67
trigger: (id: string) => void
@@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
4849
submenu: [
4950
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
5051
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
52+
{
53+
label: "New Window",
54+
accelerator: "Cmd+Shift+N",
55+
click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
56+
},
5157
{ type: "separator" },
5258
{ role: "close" },
5359
],

packages/desktop-electron/src/main/windows.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types"
66

77
type Globals = {
88
updaterEnabled: boolean
9-
wsl: boolean
109
deepLinks?: string[]
1110
}
1211

1312
const root = dirname(fileURLToPath(import.meta.url))
1413

14+
let backgroundColor: string | undefined
15+
16+
export function setBackgroundColor(color: string) {
17+
backgroundColor = color
18+
}
19+
20+
export function getBackgroundColor(): string | undefined {
21+
return backgroundColor
22+
}
23+
1524
function iconsDir() {
1625
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
1726
}
@@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) {
5968
show: true,
6069
title: "OpenCode",
6170
icon: iconPath(),
71+
backgroundColor,
6272
...(process.platform === "darwin"
6373
? {
6474
titleBarStyle: "hidden" as const,
@@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) {
95105
center: true,
96106
show: true,
97107
icon: iconPath(),
108+
backgroundColor,
98109
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
99110
...(process.platform === "win32"
100111
? {
@@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
131142
const deepLinks = globals.deepLinks ?? []
132143
const data = {
133144
updaterEnabled: globals.updaterEnabled,
134-
wsl: globals.wsl,
135145
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
136146
}
137147
void win.webContents.executeJavaScript(

packages/desktop-electron/src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const api: ElectronAPI = {
2828
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
2929
storeLength: (name) => ipcRenderer.invoke("store-length", name),
3030

31+
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
3132
onSqliteMigrationProgress: (cb) => {
3233
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
3334
ipcRenderer.on("sqlite-migration-progress", handler)
@@ -62,6 +63,7 @@ const api: ElectronAPI = {
6263
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
6364
checkUpdate: () => ipcRenderer.invoke("check-update"),
6465
installUpdate: () => ipcRenderer.invoke("install-update"),
66+
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
6567
}
6668

6769
contextBridge.exposeInMainWorld("api", api)

packages/desktop-electron/src/preload/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type ElectronAPI = {
3636
storeKeys: (name: string) => Promise<string[]>
3737
storeLength: (name: string) => Promise<number>
3838

39+
getWindowCount: () => Promise<number>
3940
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
4041
onMenuCommand: (cb: (id: string) => void) => () => void
4142
onDeepLink: (cb: (urls: string[]) => void) => () => void
@@ -66,4 +67,5 @@ export type ElectronAPI = {
6667
runUpdater: (alertOnFail: boolean) => Promise<void>
6768
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
6869
installUpdate: () => Promise<void>
70+
setBackgroundColor: (color: string) => Promise<void>
6971
}

packages/desktop-electron/src/renderer/index.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ import {
1010
useCommand,
1111
} from "@opencode-ai/app"
1212
import type { AsyncStorage } from "@solid-primitives/storage"
13-
import { createResource, onCleanup, onMount, Show } from "solid-js"
14-
import { render } from "solid-js/web"
1513
import { MemoryRouter } from "@solidjs/router"
14+
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
15+
import { render } from "solid-js/web"
1616
import pkg from "../../package.json"
1717
import { initI18n, t } from "./i18n"
1818
import { UPDATER_ENABLED } from "./updater"
1919
import { webviewZoom } from "./webview-zoom"
2020
import "./styles.css"
21+
import { useTheme } from "@opencode-ai/ui/theme"
2122

2223
const root = document.getElementById("root")
2324
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -226,7 +227,9 @@ const createPlatform = (): Platform => {
226227
const image = await window.api.readClipboardImage().catch(() => null)
227228
if (!image) return null
228229
const blob = new Blob([image.buffer], { type: "image/png" })
229-
return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
230+
return new File([blob], `pasted-image-${Date.now()}.png`, {
231+
type: "image/png",
232+
})
230233
},
231234
}
232235
}
@@ -240,6 +243,8 @@ listenForDeepLinks()
240243
render(() => {
241244
const platform = createPlatform()
242245

246+
const [windowCount] = createResource(() => window.api.getWindowCount())
247+
243248
// Fetch sidecar credentials (available immediately, before health check)
244249
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
245250

@@ -276,6 +281,18 @@ render(() => {
276281
function Inner() {
277282
const cmd = useCommand()
278283
menuTrigger = (id) => cmd.trigger(id)
284+
285+
const theme = useTheme()
286+
287+
createEffect(() => {
288+
theme.themeId()
289+
theme.mode()
290+
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
291+
if (bg) {
292+
void window.api.setBackgroundColor(bg)
293+
}
294+
})
295+
279296
return null
280297
}
281298

@@ -289,13 +306,14 @@ render(() => {
289306
return (
290307
<PlatformProvider value={platform}>
291308
<AppBaseProviders>
292-
<Show when={!defaultServer.loading && !sidecar.loading}>
309+
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
293310
{(_) => {
294311
return (
295312
<AppInterface
296313
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
297314
servers={servers()}
298315
router={MemoryRouter}
316+
disableHealthCheck={(windowCount() ?? 0) > 1}
299317
>
300318
<Inner />
301319
</AppInterface>

packages/desktop/src/menu.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
2+
import { openUrl } from "@tauri-apps/plugin-opener"
23
import { type as ostype } from "@tauri-apps/plugin-os"
34
import { relaunch } from "@tauri-apps/plugin-process"
4-
import { openUrl } from "@tauri-apps/plugin-opener"
5-
6-
import { runUpdater, UPDATER_ENABLED } from "./updater"
5+
import { commands } from "./bindings"
76
import { installCli } from "./cli"
87
import { initI18n, t } from "./i18n"
9-
import { commands } from "./bindings"
8+
import { runUpdater, UPDATER_ENABLED } from "./updater"
109

1110
export async function createMenu(trigger: (id: string) => void) {
1211
if (ostype() !== "macos") return

0 commit comments

Comments
 (0)