Skip to content

Commit b76ead3

Browse files
authored
refactor(desktop): rework default server initialization and connection handling (anomalyco#16965)
1 parent 51835ec commit b76ead3

19 files changed

Lines changed: 400 additions & 584 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
"@shikijs/transformers": "3.9.2",
4646
"@solid-primitives/active-element": "2.1.3",
4747
"@solid-primitives/audio": "1.4.2",
48-
"@solid-primitives/i18n": "2.2.1",
4948
"@solid-primitives/event-bus": "1.1.2",
49+
"@solid-primitives/i18n": "2.2.1",
5050
"@solid-primitives/media": "2.3.3",
5151
"@solid-primitives/resize-observer": "2.1.3",
5252
"@solid-primitives/scroll": "2.1.3",
@@ -56,6 +56,7 @@
5656
"@solidjs/router": "catalog:",
5757
"@thisbeyond/solid-dnd": "0.7.5",
5858
"diff": "catalog:",
59+
"effect": "4.0.0-beta.29",
5960
"fuzzysort": "catalog:",
6061
"ghostty-web": "github:anomalyco/ghostty-web#main",
6162
"luxon": "catalog:",

packages/app/src/app.tsx

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import "@/index.css"
2-
import { File } from "@opencode-ai/ui/file"
32
import { I18nProvider } from "@opencode-ai/ui/context"
43
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
54
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
65
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
6+
import { File } from "@opencode-ai/ui/file"
77
import { Font } from "@opencode-ai/ui/font"
8+
import { Splash } from "@opencode-ai/ui/logo"
89
import { ThemeProvider } from "@opencode-ai/ui/theme"
910
import { MetaProvider } from "@solidjs/meta"
10-
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
11-
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
11+
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
12+
import { type Duration, Effect } from "effect"
13+
import {
14+
type Component,
15+
createResource,
16+
createSignal,
17+
ErrorBoundary,
18+
For,
19+
type JSX,
20+
lazy,
21+
onCleanup,
22+
type ParentProps,
23+
Show,
24+
Suspense,
25+
} from "solid-js"
26+
import { Dynamic } from "solid-js/web"
1227
import { CommandProvider } from "@/context/command"
1328
import { CommentsProvider } from "@/context/comments"
1429
import { FileProvider } from "@/context/file"
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
2237
import { PermissionProvider } from "@/context/permission"
2338
import { usePlatform } from "@/context/platform"
2439
import { PromptProvider } from "@/context/prompt"
25-
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
40+
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
2641
import { SettingsProvider } from "@/context/settings"
2742
import { TerminalProvider } from "@/context/terminal"
2843
import DirectoryLayout from "@/pages/directory-layout"
2944
import Layout from "@/pages/layout"
3045
import { ErrorPage } from "./pages/error"
31-
import { Dynamic } from "solid-js/web"
46+
import { useCheckServerHealth } from "./utils/server-health"
3247

3348
const Home = lazy(() => import("@/pages/home"))
3449
const Session = lazy(() => import("@/pages/session"))
@@ -139,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
139154
)
140155
}
141156

142-
function ServerKey(props: ParentProps) {
157+
const effectMinDuration =
158+
(duration: Duration.Input) =>
159+
<A, E, R>(e: Effect.Effect<A, E, R>) =>
160+
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
161+
162+
function ConnectionGate(props: ParentProps) {
143163
const server = useServer()
164+
const checkServerHealth = useCheckServerHealth()
165+
166+
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
167+
168+
// performs repeated health check with a grace period for
169+
// non-http connections, otherwise fails instantly
170+
const [startupHealthCheck, healthCheckActions] = createResource(() =>
171+
Effect.gen(function* () {
172+
if (!server.current) return true
173+
const { http, type } = server.current
174+
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+
),
186+
)
187+
144188
return (
145-
<Show when={server.key} keyed>
146-
{props.children}
189+
<Show
190+
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
191+
fallback={
192+
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
193+
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
194+
</div>
195+
}
196+
>
197+
<Show
198+
when={startupHealthCheck()}
199+
fallback={
200+
<ConnectionError
201+
onRetry={() => {
202+
if (checkMode() === "background") healthCheckActions.refetch()
203+
}}
204+
onServerSelected={(key) => {
205+
setCheckMode("blocking")
206+
server.setActive(key)
207+
healthCheckActions.refetch()
208+
}}
209+
/>
210+
}
211+
>
212+
{props.children}
213+
</Show>
147214
</Show>
148215
)
149216
}
150217

218+
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
219+
const server = useServer()
220+
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
221+
222+
const timer = setInterval(() => props.onRetry?.(), 1000)
223+
onCleanup(() => clearInterval(timer))
224+
225+
return (
226+
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
227+
<div class="flex flex-col items-center max-w-md text-center">
228+
<Splash class="w-12 h-15 mb-4" />
229+
<p class="text-14-regular text-text-base">
230+
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
231+
</p>
232+
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
233+
</div>
234+
<Show when={others().length > 0}>
235+
<div class="flex flex-col gap-2 w-full max-w-sm">
236+
<span class="text-12-regular text-text-base text-center">Other servers</span>
237+
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
238+
<For each={others()}>
239+
{(conn) => {
240+
const key = ServerConnection.key(conn)
241+
return (
242+
<button
243+
type="button"
244+
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
245+
onClick={() => props.onServerSelected?.(key)}
246+
>
247+
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
248+
</button>
249+
)
250+
}}
251+
</For>
252+
</div>
253+
</div>
254+
</Show>
255+
</div>
256+
)
257+
}
258+
151259
export function AppInterface(props: {
152260
children?: JSX.Element
153261
defaultServer: ServerConnection.Key
@@ -156,7 +264,7 @@ export function AppInterface(props: {
156264
}) {
157265
return (
158266
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
159-
<ServerKey>
267+
<ConnectionGate>
160268
<GlobalSDKProvider>
161269
<GlobalSyncProvider>
162270
<Dynamic
@@ -171,7 +279,7 @@ export function AppInterface(props: {
171279
</Dynamic>
172280
</GlobalSyncProvider>
173281
</GlobalSDKProvider>
174-
</ServerKey>
282+
</ConnectionGate>
175283
</ServerProvider>
176284
)
177285
}

packages/app/src/components/dialog-select-server.tsx

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
1414
import { useLanguage } from "@/context/language"
1515
import { usePlatform } from "@/context/platform"
1616
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
17-
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
17+
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
1818

1919
const DEFAULT_USERNAME = "opencode"
2020

@@ -43,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
4343
})
4444
}
4545

46-
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
47-
const [defaultUrl, defaultUrlActions] = createResource(
46+
function useDefaultServer() {
47+
const language = useLanguage()
48+
const platform = usePlatform()
49+
const [defaultKey, defaultUrlActions] = createResource(
4850
async () => {
4951
try {
50-
const url = await platform.getDefaultServerUrl?.()
51-
if (!url) return null
52-
return normalizeServerUrl(url) ?? null
52+
const key = await platform.getDefaultServer?.()
53+
if (!key) return null
54+
return key
5355
} catch (err) {
5456
showRequestError(language, err)
5557
return null
@@ -58,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
5860
{ initialValue: null },
5961
)
6062

61-
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
62-
const setDefault = async (url: string | null) => {
63+
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
64+
const setDefault = async (key: ServerConnection.Key | null) => {
6365
try {
64-
await platform.setDefaultServerUrl?.(url)
65-
defaultUrlActions.mutate(url)
66+
await platform.setDefaultServer?.(key)
67+
defaultUrlActions.mutate(key)
6668
} catch (err) {
6769
showRequestError(language, err)
6870
}
6971
}
7072

71-
return { defaultUrl, canDefault, setDefault }
73+
return { defaultKey, canDefault, setDefault }
7274
}
7375

74-
function useServerPreview(fetcher: typeof fetch) {
76+
function useServerPreview() {
77+
const checkServerHealth = useCheckServerHealth()
78+
7579
const looksComplete = (value: string) => {
7680
const normalized = normalizeServerUrl(value)
7781
if (!normalized) return false
@@ -94,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
9498
const http: ServerConnection.HttpBase = { url: normalized }
9599
if (username) http.username = username
96100
if (password) http.password = password
97-
const result = await checkServerHealth(http, fetcher)
101+
const result = await checkServerHealth(http)
98102
setStatus(result.healthy)
99103
}
100104

@@ -172,9 +176,9 @@ export function DialogSelectServer() {
172176
const server = useServer()
173177
const platform = usePlatform()
174178
const language = useLanguage()
175-
const fetcher = platform.fetch ?? globalThis.fetch
176-
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
177-
const { previewStatus } = useServerPreview(fetcher)
179+
const { defaultKey, canDefault, setDefault } = useDefaultServer()
180+
const { previewStatus } = useServerPreview()
181+
const checkServerHealth = useCheckServerHealth()
178182
const [store, setStore] = createStore({
179183
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
180184
addServer: {
@@ -266,7 +270,7 @@ export function DialogSelectServer() {
266270
const results: Record<ServerConnection.Key, ServerHealth> = {}
267271
await Promise.all(
268272
items().map(async (conn) => {
269-
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
273+
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
270274
}),
271275
)
272276
setStore("status", reconcile(results))
@@ -366,7 +370,7 @@ export function DialogSelectServer() {
366370
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
367371
if (store.addServer.password) conn.http.password = store.addServer.password
368372
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
369-
const result = await checkServerHealth(conn.http, fetcher)
373+
const result = await checkServerHealth(conn.http)
370374
setStore("addServer", { adding: false })
371375
if (!result.healthy) {
372376
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -406,7 +410,7 @@ export function DialogSelectServer() {
406410
displayName: name,
407411
http: { url: normalized, username, password },
408412
}
409-
const result = await checkServerHealth(conn.http, fetcher)
413+
const result = await checkServerHealth(conn.http)
410414
setStore("editServer", { busy: false })
411415
if (!result.healthy) {
412416
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -496,8 +500,8 @@ export function DialogSelectServer() {
496500

497501
async function handleRemove(url: ServerConnection.Key) {
498502
server.remove(url)
499-
if ((await platform.getDefaultServerUrl?.()) === url) {
500-
platform.setDefaultServerUrl?.(null)
503+
if ((await platform.getDefaultServer?.()) === url) {
504+
platform.setDefaultServer?.(null)
501505
}
502506
}
503507

@@ -553,7 +557,7 @@ export function DialogSelectServer() {
553557
status={store.status[key]}
554558
class="flex items-center gap-3 min-w-0 flex-1"
555559
badge={
556-
<Show when={defaultUrl() === i.http.url}>
560+
<Show when={defaultKey() === ServerConnection.key(i)}>
557561
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
558562
{language.t("dialog.server.status.default")}
559563
</span>
@@ -586,14 +590,14 @@ export function DialogSelectServer() {
586590
>
587591
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
588592
</DropdownMenu.Item>
589-
<Show when={canDefault() && defaultUrl() !== i.http.url}>
590-
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
593+
<Show when={canDefault() && defaultKey() !== key}>
594+
<DropdownMenu.Item onSelect={() => setDefault(key)}>
591595
<DropdownMenu.ItemLabel>
592596
{language.t("dialog.server.menu.default")}
593597
</DropdownMenu.ItemLabel>
594598
</DropdownMenu.Item>
595599
</Show>
596-
<Show when={canDefault() && defaultUrl() === i.http.url}>
600+
<Show when={canDefault() && defaultKey() === key}>
597601
<DropdownMenu.Item onSelect={() => setDefault(null)}>
598602
<DropdownMenu.ItemLabel>
599603
{language.t("dialog.server.menu.defaultRemove")}

0 commit comments

Comments
 (0)