Procházet zdrojové kódy

refactor(desktop): rework default server initialization and connection handling (#16965)

Brendan Allan před 1 měsícem
rodič
revize
b76ead3fe8

+ 2 - 0
bun.lock

@@ -46,6 +46,7 @@
         "@solidjs/router": "catalog:",
         "@thisbeyond/solid-dnd": "0.7.5",
         "diff": "catalog:",
+        "effect": "4.0.0-beta.29",
         "fuzzysort": "catalog:",
         "ghostty-web": "github:anomalyco/ghostty-web#main",
         "luxon": "catalog:",
@@ -226,6 +227,7 @@
         "@solid-primitives/storage": "catalog:",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "0.15.4",
+        "effect": "4.0.0-beta.29",
         "electron-log": "^5",
         "electron-store": "^10",
         "electron-updater": "^6",

+ 2 - 1
packages/app/package.json

@@ -45,8 +45,8 @@
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/audio": "1.4.2",
-    "@solid-primitives/i18n": "2.2.1",
     "@solid-primitives/event-bus": "1.1.2",
+    "@solid-primitives/i18n": "2.2.1",
     "@solid-primitives/media": "2.3.3",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
@@ -56,6 +56,7 @@
     "@solidjs/router": "catalog:",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
+    "effect": "4.0.0-beta.29",
     "fuzzysort": "catalog:",
     "ghostty-web": "github:anomalyco/ghostty-web#main",
     "luxon": "catalog:",

+ 118 - 10
packages/app/src/app.tsx

@@ -1,14 +1,29 @@
 import "@/index.css"
-import { File } from "@opencode-ai/ui/file"
 import { I18nProvider } from "@opencode-ai/ui/context"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { FileComponentProvider } from "@opencode-ai/ui/context/file"
 import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { File } from "@opencode-ai/ui/file"
 import { Font } from "@opencode-ai/ui/font"
+import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
-import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
-import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
+import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { type Duration, Effect } from "effect"
+import {
+  type Component,
+  createResource,
+  createSignal,
+  ErrorBoundary,
+  For,
+  type JSX,
+  lazy,
+  onCleanup,
+  type ParentProps,
+  Show,
+  Suspense,
+} from "solid-js"
+import { Dynamic } from "solid-js/web"
 import { CommandProvider } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
 import { PermissionProvider } from "@/context/permission"
 import { usePlatform } from "@/context/platform"
 import { PromptProvider } from "@/context/prompt"
-import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
+import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
 import { SettingsProvider } from "@/context/settings"
 import { TerminalProvider } from "@/context/terminal"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
-import { Dynamic } from "solid-js/web"
+import { useCheckServerHealth } from "./utils/server-health"
 
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
@@ -139,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
   )
 }
 
-function ServerKey(props: ParentProps) {
+const effectMinDuration =
+  (duration: Duration.Input) =>
+  <A, E, R>(e: Effect.Effect<A, E, R>) =>
+    Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
+
+function ConnectionGate(props: ParentProps) {
   const server = useServer()
+  const checkServerHealth = useCheckServerHealth()
+
+  const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
+
+  // 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
+
+      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 (
-    <Show when={server.key} keyed>
-      {props.children}
+    <Show
+      when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
+      fallback={
+        <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+          <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+        </div>
+      }
+    >
+      <Show
+        when={startupHealthCheck()}
+        fallback={
+          <ConnectionError
+            onRetry={() => {
+              if (checkMode() === "background") healthCheckActions.refetch()
+            }}
+            onServerSelected={(key) => {
+              setCheckMode("blocking")
+              server.setActive(key)
+              healthCheckActions.refetch()
+            }}
+          />
+        }
+      >
+        {props.children}
+      </Show>
     </Show>
   )
 }
 
+function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
+  const server = useServer()
+  const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
+
+  const timer = setInterval(() => props.onRetry?.(), 1000)
+  onCleanup(() => clearInterval(timer))
+
+  return (
+    <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
+      <div class="flex flex-col items-center max-w-md text-center">
+        <Splash class="w-12 h-15 mb-4" />
+        <p class="text-14-regular text-text-base">
+          Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
+        </p>
+        <p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
+      </div>
+      <Show when={others().length > 0}>
+        <div class="flex flex-col gap-2 w-full max-w-sm">
+          <span class="text-12-regular text-text-base text-center">Other servers</span>
+          <div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
+            <For each={others()}>
+              {(conn) => {
+                const key = ServerConnection.key(conn)
+                return (
+                  <button
+                    type="button"
+                    class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
+                    onClick={() => props.onServerSelected?.(key)}
+                  >
+                    <span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      </Show>
+    </div>
+  )
+}
+
 export function AppInterface(props: {
   children?: JSX.Element
   defaultServer: ServerConnection.Key
@@ -156,7 +264,7 @@ export function AppInterface(props: {
 }) {
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
-      <ServerKey>
+      <ConnectionGate>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
             <Dynamic
@@ -171,7 +279,7 @@ export function AppInterface(props: {
             </Dynamic>
           </GlobalSyncProvider>
         </GlobalSDKProvider>
-      </ServerKey>
+      </ConnectionGate>
     </ServerProvider>
   )
 }

+ 29 - 25
packages/app/src/components/dialog-select-server.tsx

@@ -14,7 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
-import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
+import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
 
 const DEFAULT_USERNAME = "opencode"
 
@@ -43,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
   })
 }
 
-function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
-  const [defaultUrl, defaultUrlActions] = createResource(
+function useDefaultServer() {
+  const language = useLanguage()
+  const platform = usePlatform()
+  const [defaultKey, defaultUrlActions] = createResource(
     async () => {
       try {
-        const url = await platform.getDefaultServerUrl?.()
-        if (!url) return null
-        return normalizeServerUrl(url) ?? null
+        const key = await platform.getDefaultServer?.()
+        if (!key) return null
+        return key
       } catch (err) {
         showRequestError(language, err)
         return null
@@ -58,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
     { initialValue: null },
   )
 
-  const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
-  const setDefault = async (url: string | null) => {
+  const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
+  const setDefault = async (key: ServerConnection.Key | null) => {
     try {
-      await platform.setDefaultServerUrl?.(url)
-      defaultUrlActions.mutate(url)
+      await platform.setDefaultServer?.(key)
+      defaultUrlActions.mutate(key)
     } catch (err) {
       showRequestError(language, err)
     }
   }
 
-  return { defaultUrl, canDefault, setDefault }
+  return { defaultKey, canDefault, setDefault }
 }
 
-function useServerPreview(fetcher: typeof fetch) {
+function useServerPreview() {
+  const checkServerHealth = useCheckServerHealth()
+
   const looksComplete = (value: string) => {
     const normalized = normalizeServerUrl(value)
     if (!normalized) return false
@@ -94,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
     const http: ServerConnection.HttpBase = { url: normalized }
     if (username) http.username = username
     if (password) http.password = password
-    const result = await checkServerHealth(http, fetcher)
+    const result = await checkServerHealth(http)
     setStatus(result.healthy)
   }
 
@@ -172,9 +176,9 @@ export function DialogSelectServer() {
   const server = useServer()
   const platform = usePlatform()
   const language = useLanguage()
-  const fetcher = platform.fetch ?? globalThis.fetch
-  const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
-  const { previewStatus } = useServerPreview(fetcher)
+  const { defaultKey, canDefault, setDefault } = useDefaultServer()
+  const { previewStatus } = useServerPreview()
+  const checkServerHealth = useCheckServerHealth()
   const [store, setStore] = createStore({
     status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
     addServer: {
@@ -266,7 +270,7 @@ export function DialogSelectServer() {
     const results: Record<ServerConnection.Key, ServerHealth> = {}
     await Promise.all(
       items().map(async (conn) => {
-        results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
+        results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
       }),
     )
     setStore("status", reconcile(results))
@@ -366,7 +370,7 @@ export function DialogSelectServer() {
     if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
     if (store.addServer.password) conn.http.password = store.addServer.password
     if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
-    const result = await checkServerHealth(conn.http, fetcher)
+    const result = await checkServerHealth(conn.http)
     setStore("addServer", { adding: false })
     if (!result.healthy) {
       setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -406,7 +410,7 @@ export function DialogSelectServer() {
       displayName: name,
       http: { url: normalized, username, password },
     }
-    const result = await checkServerHealth(conn.http, fetcher)
+    const result = await checkServerHealth(conn.http)
     setStore("editServer", { busy: false })
     if (!result.healthy) {
       setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -496,8 +500,8 @@ export function DialogSelectServer() {
 
   async function handleRemove(url: ServerConnection.Key) {
     server.remove(url)
-    if ((await platform.getDefaultServerUrl?.()) === url) {
-      platform.setDefaultServerUrl?.(null)
+    if ((await platform.getDefaultServer?.()) === url) {
+      platform.setDefaultServer?.(null)
     }
   }
 
@@ -553,7 +557,7 @@ export function DialogSelectServer() {
                     status={store.status[key]}
                     class="flex items-center gap-3 min-w-0 flex-1"
                     badge={
-                      <Show when={defaultUrl() === i.http.url}>
+                      <Show when={defaultKey() === ServerConnection.key(i)}>
                         <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
                           {language.t("dialog.server.status.default")}
                         </span>
@@ -586,14 +590,14 @@ export function DialogSelectServer() {
                             >
                               <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
-                            <Show when={canDefault() && defaultUrl() !== i.http.url}>
-                              <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
+                            <Show when={canDefault() && defaultKey() !== key}>
+                              <DropdownMenu.Item onSelect={() => setDefault(key)}>
                                 <DropdownMenu.ItemLabel>
                                   {language.t("dialog.server.menu.default")}
                                 </DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
                             </Show>
-                            <Show when={canDefault() && defaultUrl() === i.http.url}>
+                            <Show when={canDefault() && defaultKey() === key}>
                               <DropdownMenu.Item onSelect={() => setDefault(null)}>
                                 <DropdownMenu.ItemLabel>
                                   {language.t("dialog.server.menu.defaultRemove")}

+ 6 - 6
packages/app/src/components/status-popover.tsx

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
-import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
+import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
 import { DialogSelectServer } from "./dialog-select-server"
 
 const pollMs = 10_000
@@ -53,7 +53,8 @@ const listServersByHealth = (
   })
 }
 
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
+  const checkServerHealth = useCheckServerHealth()
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
   createEffect(() => {
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
       const results: Record<string, ServerHealth> = {}
       await Promise.all(
         list.map(async (conn) => {
-          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
+          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
         }),
       )
       if (dead) return
@@ -168,7 +169,6 @@ export function StatusPopover() {
   const language = useLanguage()
   const navigate = useNavigate()
 
-  const fetcher = platform.fetch ?? globalThis.fetch
   const servers = createMemo(() => {
     const current = server.current
     const list = server.list
@@ -176,10 +176,10 @@ export function StatusPopover() {
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
   })
-  const health = useServerHealth(servers, fetcher)
+  const health = useServerHealth(servers)
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const mcp = useMcpToggle({ sync, sdk, language })
-  const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
+  const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
   const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
   const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)

+ 3 - 2
packages/app/src/context/platform.tsx

@@ -1,6 +1,7 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 import type { Accessor } from "solid-js"
+import { ServerConnection } from "./server"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -58,10 +59,10 @@ export type Platform = {
   fetch?: typeof fetch
 
   /** Get the configured default server URL (platform-specific) */
-  getDefaultServerUrl?(): Promise<string | null>
+  getDefaultServer?(): Promise<ServerConnection.Key | null>
 
   /** Set the default server URL to use on app startup (platform-specific) */
-  setDefaultServerUrl?(url: string | null): Promise<void> | void
+  setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
 
   /** Get the configured WSL integration (desktop only) */
   getWslEnabled?(): Promise<boolean>

+ 3 - 5
packages/app/src/context/server.tsx

@@ -1,9 +1,8 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { usePlatform } from "@/context/platform"
 import { Persist, persisted } from "@/utils/persist"
-import { checkServerHealth } from "@/utils/server-health"
+import { useCheckServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -96,7 +95,7 @@ export namespace ServerConnection {
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
   init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
-    const platform = usePlatform()
+    const checkServerHealth = useCheckServerHealth()
 
     const [store, setStore, _, ready] = persisted(
       Persist.global("server", ["server.v3"]),
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!state.active)
 
-    const fetcher = platform.fetch ?? globalThis.fetch
-    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
+    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
 
     createEffect(() => {
       const current_ = current()

+ 20 - 13
packages/app/src/entry.tsx

@@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
   throw new Error(getRootNotFoundError())
 }
 
+const getCurrentUrl = () => {
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (import.meta.env.DEV)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+  return location.origin
+}
+
+const getDefaultUrl = () => {
+  const lsDefault = readDefaultServerUrl()
+  if (lsDefault) return lsDefault
+  return getCurrentUrl()
+}
+
 const platform: Platform = {
   platform: "web",
   version: pkg.version,
@@ -106,26 +119,20 @@ const platform: Platform = {
   forward,
   restart,
   notify,
-  getDefaultServerUrl: async () => readDefaultServerUrl(),
-  setDefaultServerUrl: writeDefaultServerUrl,
+  getDefaultServer: async () => {
+    const stored = readDefaultServerUrl()
+    return stored ? ServerConnection.Key.make(stored) : null
+  },
+  setDefaultServer: writeDefaultServerUrl,
 }
 
-const defaultUrl = iife(() => {
-  const lsDefault = readDefaultServerUrl()
-  if (lsDefault) return lsDefault
-  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
-  if (import.meta.env.DEV)
-    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
-  return location.origin
-})
-
 if (root instanceof HTMLElement) {
-  const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
+  const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
   render(
     () => (
       <PlatformProvider value={platform}>
         <AppBaseProviders>
-          <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
+          <AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
         </AppBaseProviders>
       </PlatformProvider>
     ),

+ 8 - 0
packages/app/src/utils/server-health.ts

@@ -1,3 +1,4 @@
+import { usePlatform } from "@/context/platform"
 import type { ServerConnection } from "@/context/server"
 import { createSdkForServer } from "./server"
 
@@ -81,3 +82,10 @@ export async function checkServerHealth(
       .catch((error) => next(count, error))
   return attempt(0).finally(() => timeout?.clear?.())
 }
+
+export function useCheckServerHealth() {
+  const platform = usePlatform()
+  const fetcher = platform.fetch ?? globalThis.fetch
+
+  return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
+}

+ 1 - 0
packages/desktop-electron/package.json

@@ -30,6 +30,7 @@
     "@solid-primitives/storage": "catalog:",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "0.15.4",
+    "effect": "4.0.0-beta.29",
     "electron-log": "^5",
     "electron-store": "^10",
     "electron-updater": "^6",

+ 41 - 98
packages/desktop-electron/src/main/index.ts

@@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
 import { initLogging } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
-import {
-  checkHealth,
-  checkHealthOrAskRetry,
-  getDefaultServerUrl,
-  getSavedServerUrl,
-  getWslConfig,
-  setDefaultServerUrl,
-  setWslConfig,
-  spawnLocalServer,
-} from "./server"
+import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
 import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
 
-type ServerConnection =
-  | { variant: "existing"; url: string }
-  | {
-      variant: "cli"
-      url: string
-      password: null | string
-      health: {
-        wait: Promise<void>
-      }
-      events: any
-    }
-
 const initEmitter = new EventEmitter()
 let initStep: InitStep = { phase: "server_waiting" }
 
 let mainWindow: BrowserWindow | null = null
-const loadingWindow: BrowserWindow | null = null
 let sidecar: CommandChild | null = null
 const loadingComplete = defer<void>()
 
@@ -131,77 +109,48 @@ function setInitStep(step: InitStep) {
   initEmitter.emit("step", step)
 }
 
-async function setupServerConnection(): Promise<ServerConnection> {
-  const customUrl = await getSavedServerUrl()
-
-  if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
-    serverReady.resolve({ url: customUrl, password: null })
-    return { variant: "existing", url: customUrl }
-  }
+async function initialize() {
+  const needsMigration = !sqliteFileExists()
+  const sqliteDone = needsMigration ? defer<void>() : undefined
+  let overlay: BrowserWindow | null = null
 
   const port = await getSidecarPort()
   const hostname = "127.0.0.1"
-  const localUrl = `http://${hostname}:${port}`
-
-  if (await checkHealth(localUrl)) {
-    serverReady.resolve({ url: localUrl, password: null })
-    return { variant: "existing", url: localUrl }
-  }
-
+  const url = `http://${hostname}:${port}`
   const password = randomUUID()
+
+  logger.log("spawning sidecar", { url })
   const { child, health, events } = spawnLocalServer(hostname, port, password)
   sidecar = child
-
-  return {
-    variant: "cli",
-    url: localUrl,
+  serverReady.resolve({
+    url,
+    username: "opencode",
     password,
-    health,
-    events,
-  }
-}
-
-async function initialize() {
-  const needsMigration = !sqliteFileExists()
-  const sqliteDone = needsMigration ? defer<void>() : undefined
+  })
 
   const loadingTask = (async () => {
-    logger.log("setting up server connection")
-    const serverConnection = await setupServerConnection()
-    logger.log("server connection ready", {
-      variant: serverConnection.variant,
-      url: serverConnection.url,
-    })
-
-    const cliHealthCheck = (() => {
-      if (serverConnection.variant == "cli") {
-        return async () => {
-          const { events, health } = serverConnection
-          events.on("sqlite", (progress: SqliteMigrationProgress) => {
-            setInitStep({ phase: "sqlite_waiting" })
-            if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
-            if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
-            if (progress.type === "Done") sqliteDone?.resolve()
-          })
-          await health.wait
-          serverReady.resolve({
-            url: serverConnection.url,
-            password: serverConnection.password,
-          })
-        }
-      } else {
-        serverReady.resolve({ url: serverConnection.url, password: null })
-        return null
-      }
-    })()
+    logger.log("sidecar connection started", { url })
 
-    logger.log("server connection started")
+    events.on("sqlite", (progress: SqliteMigrationProgress) => {
+      setInitStep({ phase: "sqlite_waiting" })
+      if (overlay) sendSqliteMigrationProgress(overlay, progress)
+      if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
+      if (progress.type === "Done") sqliteDone?.resolve()
+    })
 
-    if (cliHealthCheck) {
-      if (needsMigration) await sqliteDone?.promise
-      cliHealthCheck?.()
+    if (needsMigration) {
+      await sqliteDone?.promise
     }
 
+    await Promise.race([
+      health.wait,
+      delay(30_000).then(() => {
+        throw new Error("Sidecar health check timed out")
+      }),
+    ]).catch((error) => {
+      logger.error("sidecar health check failed", error)
+    })
+
     logger.log("loading task finished")
   })()
 
@@ -211,32 +160,26 @@ async function initialize() {
     deepLinks: pendingDeepLinks,
   }
 
-  const loadingWindow = await (async () => {
-    if (needsMigration /** TOOD: 1 second timeout */) {
-      // showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
-      const loadingWindow = createLoadingWindow(globals)
-      await delay(1000)
-      return loadingWindow
-    } else {
-      logger.log("showing main window without loading window")
-      mainWindow = createMainWindow(globals)
-      wireMenu()
+  wireMenu()
+
+  if (needsMigration) {
+    const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
+    if (show) {
+      overlay = createLoadingWindow(globals)
+      await delay(1_000)
     }
-  })()
+  }
 
   await loadingTask
   setInitStep({ phase: "done" })
 
-  if (loadingWindow) {
+  if (overlay) {
     await loadingComplete.promise
   }
 
-  if (!mainWindow) {
-    mainWindow = createMainWindow(globals)
-    wireMenu()
-  }
+  mainWindow = createMainWindow(globals)
 
-  loadingWindow?.close()
+  overlay?.close()
 }
 
 function wireMenu() {

+ 1 - 44
packages/desktop-electron/src/main/server.ts

@@ -1,6 +1,4 @@
-import { dialog } from "electron"
-
-import { getConfig, serve, type CommandChild, type Config } from "./cli"
+import { serve, type CommandChild } from "./cli"
 import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
 import { store } from "./store"
 
@@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) {
   store.set(WSL_ENABLED_KEY, config.enabled)
 }
 
-export async function getSavedServerUrl(): Promise<string | null> {
-  const direct = getDefaultServerUrl()
-  if (direct) return direct
-
-  const config = await getConfig().catch(() => null)
-  if (!config) return null
-  return getServerUrlFromConfig(config)
-}
-
 export function spawnLocalServer(hostname: string, port: number, password: string) {
   const { child, exit, events } = serve(hostname, port, password)
 
@@ -94,36 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis
   }
 }
 
-export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
-  while (true) {
-    if (await checkHealth(url)) return true
-
-    const result = await dialog.showMessageBox({
-      type: "warning",
-      message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
-      title: "Connection Failed",
-      buttons: ["Retry", "Start Local"],
-      defaultId: 0,
-      cancelId: 1,
-    })
-
-    if (result.response === 0) continue
-    return false
-  }
-}
-
-export function normalizeHostnameForUrl(hostname: string) {
-  if (hostname === "0.0.0.0") return "127.0.0.1"
-  if (hostname === "::") return "[::1]"
-  if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
-  return hostname
-}
-
-export function getServerUrlFromConfig(config: Config) {
-  const server = config.server
-  if (!server?.port) return null
-  const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
-  return `http://${host}:${server.port}`
-}
-
 export type { CommandChild }

+ 1 - 0
packages/desktop-electron/src/preload/types.ts

@@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
 
 export type ServerReadyData = {
   url: string
+  username: string | null
   password: string | null
 }
 

+ 45 - 49
packages/desktop-electron/src/renderer/index.tsx

@@ -9,9 +9,8 @@ import {
   ServerConnection,
   useCommand,
 } from "@opencode-ai/app"
-import { Splash } from "@opencode-ai/ui/logo"
 import type { AsyncStorage } from "@solid-primitives/storage"
-import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
+import { createResource, onCleanup, onMount, Show } from "solid-js"
 import { render } from "solid-js/web"
 import { MemoryRouter } from "@solidjs/router"
 import pkg from "../../package.json"
@@ -19,7 +18,6 @@ import { initI18n, t } from "./i18n"
 import { UPDATER_ENABLED } from "./updater"
 import { webviewZoom } from "./webview-zoom"
 import "./styles.css"
-import type { ServerReadyData } from "../preload/types"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -198,11 +196,13 @@ const createPlatform = (): Platform => {
       await window.api.setWslConfig({ enabled })
     },
 
-    getDefaultServerUrl: async () => {
-      return window.api.getDefaultServerUrl().catch(() => null)
+    getDefaultServer: async () => {
+      const url = await window.api.getDefaultServerUrl().catch(() => null)
+      if (!url) return null
+      return ServerConnection.Key.make(url)
     },
 
-    setDefaultServerUrl: async (url: string | null) => {
+    setDefaultServer: async (url: string | null) => {
       await window.api.setDefaultServerUrl(url)
     },
 
@@ -240,6 +240,31 @@ listenForDeepLinks()
 render(() => {
   const platform = createPlatform()
 
+  // Fetch sidecar credentials (available immediately, before health check)
+  const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
+
+  const [defaultServer] = createResource(() =>
+    platform.getDefaultServer?.().then((url) => {
+      if (url) return ServerConnection.key({ type: "http", http: { url } })
+    }),
+  )
+
+  const servers = () => {
+    const data = sidecar()
+    if (!data) return []
+    const server: ServerConnection.Sidecar = {
+      displayName: "Local Server",
+      type: "sidecar",
+      variant: "base",
+      http: {
+        url: data.url,
+        username: data.username ?? undefined,
+        password: data.password ?? undefined,
+      },
+    }
+    return [server] as ServerConnection.Any[]
+  }
+
   function handleClick(e: MouseEvent) {
     const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
     if (link?.href) {
@@ -248,6 +273,12 @@ render(() => {
     }
   }
 
+  function Inner() {
+    const cmd = useCommand()
+    menuTrigger = (id) => cmd.trigger(id)
+    return null
+  }
+
   onMount(() => {
     document.addEventListener("click", handleClick)
     onCleanup(() => {
@@ -258,55 +289,20 @@ render(() => {
   return (
     <PlatformProvider value={platform}>
       <AppBaseProviders>
-        <ServerGate>
-          {(data) => {
-            const server: ServerConnection.Sidecar = {
-              displayName: "Local Server",
-              type: "sidecar",
-              variant: "base",
-              http: {
-                url: data().url,
-                username: "opencode",
-                password: data().password ?? undefined,
-              },
-            }
-
-            function Inner() {
-              const cmd = useCommand()
-
-              menuTrigger = (id) => cmd.trigger(id)
-
-              return null
-            }
-
+        <Show when={!defaultServer.loading && !sidecar.loading}>
+          {(_) => {
             return (
-              <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
+              <AppInterface
+                defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
+                servers={servers()}
+                router={MemoryRouter}
+              >
                 <Inner />
               </AppInterface>
             )
           }}
-        </ServerGate>
+        </Show>
       </AppBaseProviders>
     </PlatformProvider>
   )
 }, root!)
-
-// Gate component that waits for the server to be ready
-function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
-  const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
-  console.log({ serverData })
-  if (serverData.state === "errored") throw serverData.error
-
-  return (
-    <Show
-      when={serverData.state !== "pending" && serverData()}
-      fallback={
-        <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
-          <Splash class="w-16 h-20 opacity-50 animate-pulse" />
-        </div>
-      }
-    >
-      {(data) => props.children(data)}
-    </Show>
-  )
-}

+ 5 - 2
packages/desktop-electron/src/renderer/loading.tsx

@@ -1,5 +1,5 @@
-import { render } from "solid-js/web"
 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"
@@ -34,7 +34,10 @@ render(() => {
 
     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)
+      if (progress.type === "Done") {
+        setPercent(100)
+        setStep({ phase: "done" })
+      }
     })
 
     onCleanup(() => {

+ 60 - 176
packages/desktop/src-tauri/src/lib.rs

@@ -12,12 +12,10 @@ mod window_customizer;
 mod windows;
 
 use crate::cli::CommandChild;
-use futures::{
-    FutureExt, TryFutureExt,
-    future::{self, Shared},
-};
+use futures::{FutureExt, TryFutureExt};
 use std::{
     env,
+    future::Future,
     net::TcpListener,
     path::PathBuf,
     process::Command,
@@ -35,7 +33,6 @@ use tokio::{
 
 use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
 use crate::constants::*;
-use crate::server::get_saved_server_url;
 use crate::windows::{LoadingWindow, MainWindow};
 
 #[derive(Clone, serde::Serialize, specta::Type, Debug)]
@@ -43,7 +40,6 @@ struct ServerReadyData {
     url: String,
     username: Option<String>,
     password: Option<String>,
-    is_sidecar: bool,
 }
 
 #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -65,27 +61,12 @@ struct InitState {
     current: watch::Receiver<InitStep>,
 }
 
-#[derive(Clone)]
 struct ServerState {
     child: Arc<Mutex<Option<CommandChild>>>,
-    status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
 }
 
-impl ServerState {
-    pub fn new(
-        child: Option<CommandChild>,
-        status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
-    ) -> Self {
-        Self {
-            child: Arc::new(Mutex::new(child)),
-            status,
-        }
-    }
-
-    pub fn set_child(&self, child: Option<CommandChild>) {
-        *self.child.lock().unwrap() = child;
-    }
-}
+/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
+struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
 
 #[tauri::command]
 #[specta::specta]
@@ -110,26 +91,21 @@ fn kill_sidecar(app: AppHandle) {
     tracing::info!("Killed server");
 }
 
-fn get_logs() -> String {
-    logging::tail()
-}
-
 #[tauri::command]
 #[specta::specta]
 async fn await_initialization(
-    state: State<'_, ServerState>,
+    state: State<'_, SidecarReady>,
     init_state: State<'_, InitState>,
     events: Channel<InitStep>,
 ) -> Result<ServerReadyData, String> {
     let mut rx = init_state.current.clone();
 
-    let events = async {
+    let stream = async {
         let e = *rx.borrow();
         let _ = events.send(e);
 
         while rx.changed().await.is_ok() {
             let step = *rx.borrow_and_update();
-
             let _ = events.send(step);
 
             if matches!(step, InitStep::Done) {
@@ -138,10 +114,18 @@ async fn await_initialization(
         }
     };
 
-    future::join(state.status.clone(), events)
-        .await
-        .0
-        .map_err(|_| "Failed to get server status".to_string())?
+    // Wait for sidecar credentials (available immediately after spawn, before health check)
+    let data = async {
+        state
+            .inner()
+            .0
+            .clone()
+            .await
+            .map_err(|_| "Failed to get sidecar data".to_string())
+    };
+
+    let (result, _) = futures::future::join(data, stream).await;
+    result
 }
 
 #[tauri::command]
@@ -439,22 +423,35 @@ async fn initialize(app: AppHandle) {
     setup_app(&app, init_rx);
     spawn_cli_sync_task(app.clone());
 
-    let (server_ready_tx, server_ready_rx) = oneshot::channel();
-    let server_ready_rx = server_ready_rx.shared();
-    app.manage(ServerState::new(None, server_ready_rx.clone()));
+    // Spawn sidecar immediately - credentials are known before health check
+    let port = get_sidecar_port();
+    let hostname = "127.0.0.1";
+    let url = format!("http://{hostname}:{port}");
+    let password = uuid::Uuid::new_v4().to_string();
+
+    tracing::info!("Spawning sidecar on {url}");
+    let (child, health_check) =
+        server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
 
-    let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
+    // Make sidecar credentials available immediately (before health check completes)
+    let (ready_tx, ready_rx) = oneshot::channel();
+    let _ = ready_tx.send(ServerReadyData {
+        url: url.clone(),
+        username: Some("opencode".to_string()),
+        password: Some(password),
+    });
+    app.manage(SidecarReady(ready_rx.shared()));
+    app.manage(ServerState {
+        child: Arc::new(Mutex::new(Some(child))),
+    });
 
-    tracing::info!("Main and loading windows created");
+    let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
 
     // SQLite migration handling:
-    // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
-    // First, we spawn a task that listens for SqliteMigrationProgress events that can
-    // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
-    // Then in the loading task, we wait for sqlite migration to complete before
-    // starting our health check against the server, otherwise long migrations could result in a timeout.
-    let needs_sqlite_migration = !sqlite_file_exists();
-    let sqlite_done = needs_sqlite_migration.then(|| {
+    // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
+    // A separate loading window is shown for long migrations.
+    let needs_migration = !sqlite_file_exists();
+    let sqlite_done = needs_migration.then(|| {
         tracing::info!(
             path = %opencode_db_path().expect("failed to get db path").display(),
             "Sqlite file not found, waiting for it to be generated"
@@ -480,80 +477,22 @@ async fn initialize(app: AppHandle) {
         }))
     });
 
+    // The loading task waits for SQLite migration (if needed) then for the sidecar health check.
+    // This is only used to drive the loading window progress - the main window is shown immediately.
     let loading_task = tokio::spawn({
-        let app = app.clone();
-
         async move {
-            tracing::info!("Setting up server connection");
-            let server_connection = setup_server_connection(app.clone()).await;
-            tracing::info!("Server connection setup");
-
-            // we delay spawning this future so that the timeout is created lazily
-            let cli_health_check = match server_connection {
-                ServerConnection::CLI {
-                    child,
-                    health_check,
-                    url,
-                    username,
-                    password,
-                } => {
-                    let app = app.clone();
-                    Some(
-                        async move {
-                            let res = timeout(Duration::from_secs(30), health_check.0).await;
-                            let err = match res {
-                                Ok(Ok(Ok(()))) => None,
-                                Ok(Ok(Err(e))) => Some(e),
-                                Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
-                                Err(_) => Some("Health check timed out".to_string()),
-                            };
-
-                            if let Some(err) = err {
-                                let _ = child.kill();
-
-                                return Err(format!(
-                                    "Failed to spawn OpenCode Server ({err}). Logs:\n{}",
-                                    get_logs()
-                                ));
-                            }
-
-                            tracing::info!("CLI health check OK");
-
-                            app.state::<ServerState>().set_child(Some(child));
-
-                            Ok(ServerReadyData {
-                                url,
-                                username,
-                                password,
-                                is_sidecar: true,
-                            })
-                        }
-                        .map(move |res| {
-                            let _ = server_ready_tx.send(res);
-                        }),
-                    )
-                }
-                ServerConnection::Existing { url } => {
-                    let _ = server_ready_tx.send(Ok(ServerReadyData {
-                        url: url.to_string(),
-                        username: None,
-                        password: None,
-                        is_sidecar: false,
-                    }));
-                    None
-                }
-            };
-
-            tracing::info!("server connection started");
-
-            if let Some(cli_health_check) = cli_health_check {
-                if let Some(sqlite_done_rx) = sqlite_done {
-                    let _ = sqlite_done_rx.await;
-                }
-                tokio::spawn(cli_health_check);
+            if let Some(sqlite_done_rx) = sqlite_done {
+                let _ = sqlite_done_rx.await;
             }
 
-            let _ = server_ready_rx.await;
+            // Wait for sidecar to become healthy (for loading window progress)
+            let res = timeout(Duration::from_secs(30), health_check.0).await;
+            match res {
+                Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
+                Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
+                Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
+                Err(_) => tracing::error!("Sidecar health check timed out"),
+            }
 
             tracing::info!("Loading task finished");
         }
@@ -561,7 +500,8 @@ async fn initialize(app: AppHandle) {
     .map_err(|_| ())
     .shared();
 
-    let loading_window = if needs_sqlite_migration
+    // Show loading window for SQLite migrations if they take >1s
+    let loading_window = if needs_migration
         && timeout(Duration::from_secs(1), loading_task.clone())
             .await
             .is_err()
@@ -571,12 +511,12 @@ async fn initialize(app: AppHandle) {
         sleep(Duration::from_secs(1)).await;
         Some(loading_window)
     } else {
-        tracing::debug!("Showing main window without loading window");
-        MainWindow::create(&app).expect("Failed to create main window");
-
         None
     };
 
+    // Create main window immediately - the web app handles its own loading/health gate
+    MainWindow::create(&app).expect("Failed to create main window");
+
     let _ = loading_task.await;
 
     tracing::info!("Loading done, completing initialisation");
@@ -584,12 +524,9 @@ async fn initialize(app: AppHandle) {
 
     if loading_window.is_some() {
         loading_window_complete.await;
-
         tracing::info!("Loading window completed");
     }
 
-    MainWindow::create(&app).expect("Failed to create main window");
-
     if let Some(loading_window) = loading_window {
         let _ = loading_window.close();
     }
@@ -610,59 +547,6 @@ fn spawn_cli_sync_task(app: AppHandle) {
     });
 }
 
-enum ServerConnection {
-    Existing {
-        url: String,
-    },
-    CLI {
-        url: String,
-        username: Option<String>,
-        password: Option<String>,
-        child: CommandChild,
-        health_check: server::HealthCheck,
-    },
-}
-
-async fn setup_server_connection(app: AppHandle) -> ServerConnection {
-    let custom_url = get_saved_server_url(&app).await;
-
-    tracing::info!(?custom_url, "Attempting server connection");
-
-    if let Some(url) = &custom_url
-        && server::check_health_or_ask_retry(&app, url).await
-    {
-        tracing::info!(%url, "Connected to custom server");
-        // If the default server is already local, no need to also spawn a sidecar
-        if server::is_localhost_url(url) {
-            return ServerConnection::Existing { url: url.clone() };
-        }
-        // Remote default server: fall through and also spawn a local sidecar
-    }
-
-    let local_port = get_sidecar_port();
-    let hostname = "127.0.0.1";
-    let local_url = format!("http://{hostname}:{local_port}");
-
-    tracing::debug!(url = %local_url, "Checking health of local server");
-    if server::check_health(&local_url, None).await {
-        tracing::info!(url = %local_url, "Health check OK, using existing server");
-        return ServerConnection::Existing { url: local_url };
-    }
-
-    let password = uuid::Uuid::new_v4().to_string();
-
-    tracing::info!("Spawning new local server");
-    let (child, health_check) =
-        server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
-
-    ServerConnection::CLI {
-        url: local_url,
-        username: Some("opencode".to_string()),
-        password: Some(password),
-        child,
-        health_check,
-    }
-}
 
 fn get_sidecar_port() -> u32 {
     option_env!("OPENCODE_PORT")

+ 11 - 94
packages/desktop/src-tauri/src/server.rs

@@ -1,7 +1,6 @@
 use std::time::{Duration, Instant};
 
 use tauri::AppHandle;
-use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_store::StoreExt;
 use tokio::task::JoinHandle;
 
@@ -85,22 +84,6 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
     Ok(())
 }
 
-pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
-    if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
-        tracing::info!(%url, "Using desktop-specific custom URL");
-        return Some(url);
-    }
-
-    if let Some(cli_config) = cli::get_config(app).await
-        && let Some(url) = get_server_url_from_config(&cli_config)
-    {
-        tracing::info!(%url, "Using custom server URL from config");
-        return Some(url);
-    }
-
-    None
-}
-
 pub fn spawn_local_server(
     app: AppHandle,
     hostname: String,
@@ -145,19 +128,27 @@ pub fn spawn_local_server(
 
 pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
 
-pub async fn check_health(url: &str, password: Option<&str>) -> bool {
+async fn check_health(url: &str, password: Option<&str>) -> bool {
     let Ok(url) = reqwest::Url::parse(url) else {
         return false;
     };
 
     let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
 
-    if url_is_localhost(&url) {
+    if url
+        .host_str()
+        .is_some_and(|host| {
+            host.eq_ignore_ascii_case("localhost")
+                || host
+                    .parse::<std::net::IpAddr>()
+                    .is_ok_and(|ip| ip.is_loopback())
+        })
+    {
         // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
         // excluding loopback. reqwest respects these by default, which can prevent the desktop
         // app from reaching its own local sidecar server.
         builder = builder.no_proxy();
-    };
+    }
 
     let Ok(client) = builder.build() else {
         return false;
@@ -177,77 +168,3 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
         .map(|r| r.status().is_success())
         .unwrap_or(false)
 }
-
-pub fn is_localhost_url(url: &str) -> bool {
-    reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
-}
-
-fn url_is_localhost(url: &reqwest::Url) -> bool {
-    url.host_str().is_some_and(|host| {
-        host.eq_ignore_ascii_case("localhost")
-            || host
-                .parse::<std::net::IpAddr>()
-                .is_ok_and(|ip| ip.is_loopback())
-    })
-}
-
-/// Converts a bind address hostname to a valid URL hostname for connection.
-/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
-/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
-fn normalize_hostname_for_url(hostname: &str) -> String {
-    // Wildcard bind addresses -> localhost equivalents
-    if hostname == "0.0.0.0" {
-        return "127.0.0.1".to_string();
-    }
-    if hostname == "::" {
-        return "[::1]".to_string();
-    }
-
-    // IPv6 addresses need brackets in URLs
-    if hostname.contains(':') && !hostname.starts_with('[') {
-        return format!("[{}]", hostname);
-    }
-
-    hostname.to_string()
-}
-
-fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
-    let server = config.server.as_ref()?;
-    let port = server.port?;
-    tracing::debug!(port, "server.port found in OC config");
-    let hostname = server
-        .hostname
-        .as_ref()
-        .map(|v| normalize_hostname_for_url(v))
-        .unwrap_or_else(|| "127.0.0.1".to_string());
-
-    Some(format!("http://{}:{}", hostname, port))
-}
-
-pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
-    tracing::debug!(%url, "Checking health");
-    loop {
-        if check_health(url, None).await {
-            return true;
-        }
-
-        const RETRY: &str = "Retry";
-
-        let res = app.dialog()
-    		  .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
-    		  .title("Connection Failed")
-    		  .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
-    		  .blocking_show_with_result();
-
-        match res {
-            MessageDialogResult::Custom(name) if name == RETRY => {
-                continue;
-            }
-            _ => {
-                break;
-            }
-        }
-    }
-
-    false
-}

+ 0 - 1
packages/desktop/src/bindings.ts

@@ -38,7 +38,6 @@ export type ServerReadyData = {
 		url: string,
 		username: string | null,
 		password: string | null,
-		is_sidecar: boolean,
 	};
 
 export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

+ 44 - 58
packages/desktop/src/index.tsx

@@ -9,7 +9,6 @@ import {
   ServerConnection,
   useCommand,
 } from "@opencode-ai/app"
-import { Splash } from "@opencode-ai/ui/logo"
 import type { AsyncStorage } from "@solid-primitives/storage"
 import { getCurrentWindow } from "@tauri-apps/api/window"
 import { readImage } from "@tauri-apps/plugin-clipboard-manager"
@@ -22,7 +21,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
 import { open as shellOpen } from "@tauri-apps/plugin-shell"
 import { Store } from "@tauri-apps/plugin-store"
 import { check, type Update } from "@tauri-apps/plugin-updater"
-import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
+import { createResource, onCleanup, onMount, Show } from "solid-js"
 import { render } from "solid-js/web"
 import pkg from "../package.json"
 import { initI18n, t } from "./i18n"
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
 import { webviewZoom } from "./webview-zoom"
 import "./styles.css"
 import { Channel } from "@tauri-apps/api/core"
-import { commands, ServerReadyData, type InitStep } from "./bindings"
+import { commands, type InitStep } from "./bindings"
 import { createMenu } from "./menu"
 
 const root = document.getElementById("root")
@@ -348,12 +347,13 @@ const createPlatform = (): Platform => {
       await commands.setWslConfig({ enabled })
     },
 
-    getDefaultServerUrl: async () => {
-      const result = await commands.getDefaultServerUrl().catch(() => null)
-      return result
+    getDefaultServer: async () => {
+      const url = await commands.getDefaultServerUrl().catch(() => null)
+      if (!url) return null
+      return ServerConnection.Key.make(url)
     },
 
-    setDefaultServerUrl: async (url: string | null) => {
+    setDefaultServer: async (url: string | null) => {
       await commands.setDefaultServerUrl(url)
     },
 
@@ -412,12 +412,33 @@ void listenForDeepLinks()
 render(() => {
   const platform = createPlatform()
 
+  // Fetch sidecar credentials from Rust (available immediately, before health check)
+  const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
+
   const [defaultServer] = createResource(() =>
-    platform.getDefaultServerUrl?.().then((url) => {
+    platform.getDefaultServer?.().then((url) => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
   )
 
+  // Build the sidecar server connection once credentials arrive
+  const servers = () => {
+    const data = sidecar()
+    if (!data) return []
+    const http = {
+      url: data.url,
+      username: data.username ?? undefined,
+      password: data.password ?? undefined,
+    }
+    const server: ServerConnection.Sidecar = {
+      displayName: t("desktop.server.local"),
+      type: "sidecar",
+      variant: "base",
+      http,
+    }
+    return [server] as ServerConnection.Any[]
+  }
+
   function handleClick(e: MouseEvent) {
     const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
     if (link?.href) {
@@ -426,6 +447,12 @@ render(() => {
     }
   }
 
+  function Inner() {
+    const cmd = useCommand()
+    menuTrigger = (id) => cmd.trigger(id)
+    return null
+  }
+
   onMount(() => {
     document.addEventListener("click", handleClick)
     onCleanup(() => {
@@ -436,60 +463,19 @@ render(() => {
   return (
     <PlatformProvider value={platform}>
       <AppBaseProviders>
-        <ServerGate>
-          {(data) => {
-            const http = {
-              url: data.url,
-              username: data.username ?? undefined,
-              password: data.password ?? undefined,
-            }
-            const server: ServerConnection.Any = data.is_sidecar
-              ? {
-                  displayName: t("desktop.server.local"),
-                  type: "sidecar",
-                  variant: "base",
-                  http,
-                }
-              : { type: "http", http }
-
-            function Inner() {
-              const cmd = useCommand()
-
-              menuTrigger = (id) => cmd.trigger(id)
-
-              return null
-            }
-
+        <Show when={!defaultServer.loading && !sidecar.loading}>
+          {(_) => {
             return (
-              <Show when={!defaultServer.loading}>
-                <AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
-                  <Inner />
-                </AppInterface>
-              </Show>
+              <AppInterface
+                defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
+                servers={servers()}
+              >
+                <Inner />
+              </AppInterface>
             )
           }}
-        </ServerGate>
+        </Show>
       </AppBaseProviders>
     </PlatformProvider>
   )
 }, root!)
-
-// Gate component that waits for the server to be ready
-function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
-  const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
-  if (serverData.state === "errored") throw serverData.error
-
-  return (
-    <Show
-      when={serverData.state !== "pending" && serverData()}
-      fallback={
-        <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
-          <Splash class="w-16 h-20 opacity-50 animate-pulse" />
-          <div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
-        </div>
-      }
-    >
-      {(data) => props.children(data())}
-    </Show>
-  )
-}