fix(desktop): smooth startup hydration (#44377)

This commit is contained in:
Brendan Allan
2026-08-23 19:07:10 +08:00
committed by GitHub
parent e765349865
commit 8cbfac3184
13 changed files with 115 additions and 37 deletions
+1
View File
@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
+5 -2
View File
@@ -65,7 +65,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [info, setInfo, , infoReady] = persisted(
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
@@ -378,6 +381,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, info, ready, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady }
},
})
@@ -2,28 +2,13 @@ import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowThemeReady: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
+7 -7
View File
@@ -7,6 +7,7 @@ import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { forgetStore, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -24,7 +25,6 @@ import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const windowIDs = new WeakMap<BrowserWindow, string>()
const themeReady = new WeakMap<BrowserWindow, () => void>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
@@ -59,10 +59,6 @@ export function setAppQuitting(quitting = true) {
registry.setQuitting(quitting)
}
export function getWindowID(win: BrowserWindow) {
return windowIDs.get(win)
}
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
@@ -89,6 +85,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const create = (id: string = randomUUID()) => {
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
const appearance = windowAppearance(path, paths)
const win = new BrowserWindow({
x: state.x,
y: state.y,
@@ -96,7 +93,11 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
height: state.height,
show: false,
autoHideMenuBar: true,
...windowAppearance(path, paths),
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: [windowIDArgument(id)],
},
})
allowRendererPermissions(win)
@@ -132,7 +133,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
}
const register = (win: BrowserWindow, id: string) => {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
+2
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -7,5 +8,6 @@ ipcRenderer.on(IpcTransportPort, (event) => {
})
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+1
View File
@@ -1,3 +1,4 @@
export type ElectronNative = {
windowID: string
getPathForFile(file: File): string
}
+1 -1
View File
@@ -42,7 +42,7 @@ export type ElectronAPI = {
draftDelete(key: string): Promise<void>
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): Promise<string>
getWindowID(): string
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+1 -1
View File
@@ -86,7 +86,7 @@ export const api: ElectronAPI = {
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => invoke("WindowGetId"),
getWindowID: () => window.electron.windowID,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+64 -6
View File
@@ -4,16 +4,22 @@
import {
AppBaseProviders,
AppInterface,
currentRoute,
PlatformProvider,
preloadRoute,
ServerConnection,
useCommand,
useCurrentRoute,
useLanguage,
useTabs,
useWslServers,
type LayoutRoute,
type UpdaterPlatform,
} from "@opencode-ai/app/desktop"
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
@@ -28,19 +34,51 @@ import { availableStartupServer, readyWslConnections } from "./wsl/connections"
const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus })))
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version })))
const windowState = { id: props.api.getWindowID(), version: props.version }
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
const route = currentRoute(url.pathname, url.search)
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
ready: false,
visible: true,
route,
})
return (
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
{(state) => <DesktopWindow api={props.api} updater={props.updater} windowState={state} />}
</Show>
<>
<DesktopWindow
api={props.api}
updater={props.updater}
windowState={windowState}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<Show when={startup.visible}>
<div
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": startup.ready }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !startup.ready) return
setStartup("visible", false)
}}
>
<LoadingSplash deep={startup.route.type === "draft"} />
</div>
</Show>
</>
)
}
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
function DesktopWindow(props: {
api: ElectronAPI
updater: UpdaterPlatform
windowState: DesktopWindowState
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
@@ -74,10 +112,15 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
)
return (
<Show when={ready()} fallback={<LoadingSplash />}>
<Show when={ready()}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
@@ -109,6 +152,21 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
)
}
function DesktopStartupReady(props: {
routeReady: () => boolean
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const tabs = useTabs()
const route = useCurrentRoute()
createEffect(() => props.onRoute(route()))
createEffect(() => {
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
props.onReady()
})
return null
}
function DesktopEffects(props: { api: ElectronAPI }) {
const command = useCommand()
bindDesktopMenu((id) => command.trigger(id))
@@ -1,8 +1,14 @@
import { Splash } from "@opencode-ai/ui/logo"
export function LoadingSplash() {
export function LoadingSplash(props: { deep: boolean }) {
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<div
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
export const WindowThemeReady = Rpc.make("WindowThemeReady")
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
@@ -26,7 +25,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
},
})
export const WindowRpcs = RpcGroup.make(
WindowGetId,
WindowThemeReady,
WindowGetFocused,
WindowGetFullscreen,
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
})
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
})
})
@@ -0,0 +1,11 @@
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
}
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
}