Skip to content

Commit b5ed098

Browse files
Apply PR #16965: refactor(desktop): rework default server initialization and connection handling
2 parents 96d2b84 + f4cda51 commit b5ed098

18 files changed

Lines changed: 386 additions & 581 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"))
@@ -132,15 +147,108 @@ export function AppBaseProviders(props: ParentProps) {
132147
)
133148
}
134149

135-
function ServerKey(props: ParentProps) {
150+
const effectMinDuration =
151+
(duration: Duration.Input) =>
152+
<A, E, R>(e: Effect.Effect<A, E, R>) =>
153+
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
154+
155+
function ConnectionGate(props: ParentProps) {
136156
const server = useServer()
157+
const checkServerHealth = useCheckServerHealth()
158+
159+
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
160+
161+
// performs repeated health check with a grace period for
162+
// non-http connections, otherwise fails instantly
163+
const [startupHealthCheck, healthCheckActions] = createResource(() =>
164+
Effect.gen(function* () {
165+
if (!server.current) return true
166+
const { http, type } = server.current
167+
168+
while (true) {
169+
const res = yield* Effect.promise(() => checkServerHealth(http))
170+
if (res.healthy) return true
171+
if (checkMode() === "background" || type === "http") return false
172+
}
173+
}).pipe(
174+
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
175+
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
176+
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
177+
Effect.runPromise,
178+
),
179+
)
180+
137181
return (
138-
<Show when={server.key} keyed>
139-
{props.children}
182+
<Show
183+
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
184+
fallback={
185+
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
186+
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
187+
</div>
188+
}
189+
>
190+
<Show
191+
when={startupHealthCheck()}
192+
fallback={
193+
<ConnectionError
194+
onRetry={() => {
195+
if (checkMode() === "background") healthCheckActions.refetch()
196+
}}
197+
onServerSelected={(key) => {
198+
setCheckMode("blocking")
199+
server.setActive(key)
200+
healthCheckActions.refetch()
201+
}}
202+
/>
203+
}
204+
>
205+
{props.children}
206+
</Show>
140207
</Show>
141208
)
142209
}
143210

211+
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
212+
const server = useServer()
213+
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
214+
215+
const timer = setInterval(() => props.onRetry?.(), 1000)
216+
onCleanup(() => clearInterval(timer))
217+
218+
return (
219+
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
220+
<div class="flex flex-col items-center max-w-md text-center">
221+
<Splash class="w-12 h-15 mb-4" />
222+
<p class="text-14-regular text-text-base">
223+
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
224+
</p>
225+
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
226+
</div>
227+
<Show when={others().length > 0}>
228+
<div class="flex flex-col gap-2 w-full max-w-sm">
229+
<span class="text-12-regular text-text-base text-center">Other servers</span>
230+
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
231+
<For each={others()}>
232+
{(conn) => {
233+
const key = ServerConnection.key(conn)
234+
return (
235+
<button
236+
type="button"
237+
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
238+
onClick={() => props.onServerSelected?.(key)}
239+
>
240+
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
241+
</button>
242+
)
243+
}}
244+
</For>
245+
</div>
246+
</div>
247+
</Show>
248+
</div>
249+
)
250+
}
251+
144252
export function AppInterface(props: {
145253
children?: JSX.Element
146254
defaultServer: ServerConnection.Key
@@ -149,7 +257,7 @@ export function AppInterface(props: {
149257
}) {
150258
return (
151259
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
152-
<ServerKey>
260+
<ConnectionGate>
153261
<GlobalSDKProvider>
154262
<GlobalSyncProvider>
155263
<Dynamic
@@ -164,7 +272,7 @@ export function AppInterface(props: {
164272
</Dynamic>
165273
</GlobalSyncProvider>
166274
</GlobalSDKProvider>
167-
</ServerKey>
275+
</ConnectionGate>
168276
</ServerProvider>
169277
)
170278
}

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 { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
1818

1919
interface ServerFormProps {
2020
value: string
@@ -41,13 +41,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
4141
})
4242
}
4343

44-
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
45-
const [defaultUrl, defaultUrlActions] = createResource(
44+
function useDefaultServer() {
45+
const language = useLanguage()
46+
const platform = usePlatform()
47+
const [defaultKey, defaultUrlActions] = createResource(
4648
async () => {
4749
try {
48-
const url = await platform.getDefaultServerUrl?.()
49-
if (!url) return null
50-
return normalizeServerUrl(url) ?? null
50+
const key = await platform.getDefaultServer?.()
51+
if (!key) return null
52+
return key
5153
} catch (err) {
5254
showRequestError(language, err)
5355
return null
@@ -56,20 +58,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
5658
{ initialValue: null },
5759
)
5860

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

69-
return { defaultUrl, canDefault, setDefault }
71+
return { defaultKey, canDefault, setDefault }
7072
}
7173

72-
function useServerPreview(fetcher: typeof fetch) {
74+
function useServerPreview() {
75+
const checkServerHealth = useCheckServerHealth()
76+
7377
const looksComplete = (value: string) => {
7478
const normalized = normalizeServerUrl(value)
7579
if (!normalized) return false
@@ -92,7 +96,7 @@ function useServerPreview(fetcher: typeof fetch) {
9296
const http: ServerConnection.HttpBase = { url: normalized }
9397
if (username) http.username = username
9498
if (password) http.password = password
95-
const result = await checkServerHealth(http, fetcher)
99+
const result = await checkServerHealth(http)
96100
setStatus(result.healthy)
97101
}
98102

@@ -170,9 +174,9 @@ export function DialogSelectServer() {
170174
const server = useServer()
171175
const platform = usePlatform()
172176
const language = useLanguage()
173-
const fetcher = platform.fetch ?? globalThis.fetch
174-
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
175-
const { previewStatus } = useServerPreview(fetcher)
177+
const { defaultKey, canDefault, setDefault } = useDefaultServer()
178+
const { previewStatus } = useServerPreview()
179+
const checkServerHealth = useCheckServerHealth()
176180
const [store, setStore] = createStore({
177181
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
178182
addServer: {
@@ -264,7 +268,7 @@ export function DialogSelectServer() {
264268
const results: Record<ServerConnection.Key, ServerHealth> = {}
265269
await Promise.all(
266270
items().map(async (conn) => {
267-
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
271+
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
268272
}),
269273
)
270274
setStore("status", reconcile(results))
@@ -364,7 +368,7 @@ export function DialogSelectServer() {
364368
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
365369
if (store.addServer.username) conn.http.username = store.addServer.username
366370
if (store.addServer.password) conn.http.password = store.addServer.password
367-
const result = await checkServerHealth(conn.http, fetcher)
371+
const result = await checkServerHealth(conn.http)
368372
setStore("addServer", { adding: false })
369373
if (!result.healthy) {
370374
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -404,7 +408,7 @@ export function DialogSelectServer() {
404408
displayName: name,
405409
http: { url: normalized, username, password },
406410
}
407-
const result = await checkServerHealth(conn.http, fetcher)
411+
const result = await checkServerHealth(conn.http)
408412
setStore("editServer", { busy: false })
409413
if (!result.healthy) {
410414
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -494,8 +498,8 @@ export function DialogSelectServer() {
494498

495499
async function handleRemove(url: ServerConnection.Key) {
496500
server.remove(url)
497-
if ((await platform.getDefaultServerUrl?.()) === url) {
498-
platform.setDefaultServerUrl?.(null)
501+
if ((await platform.getDefaultServer?.()) === url) {
502+
platform.setDefaultServer?.(null)
499503
}
500504
}
501505

@@ -551,7 +555,7 @@ export function DialogSelectServer() {
551555
status={store.status[key]}
552556
class="flex items-center gap-3 min-w-0 flex-1"
553557
badge={
554-
<Show when={defaultUrl() === i.http.url}>
558+
<Show when={defaultKey() === i.http.url}>
555559
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
556560
{language.t("dialog.server.status.default")}
557561
</span>
@@ -584,14 +588,14 @@ export function DialogSelectServer() {
584588
>
585589
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
586590
</DropdownMenu.Item>
587-
<Show when={canDefault() && defaultUrl() !== i.http.url}>
588-
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
591+
<Show when={canDefault() && defaultKey() !== key}>
592+
<DropdownMenu.Item onSelect={() => setDefault(key)}>
589593
<DropdownMenu.ItemLabel>
590594
{language.t("dialog.server.menu.default")}
591595
</DropdownMenu.ItemLabel>
592596
</DropdownMenu.Item>
593597
</Show>
594-
<Show when={canDefault() && defaultUrl() === i.http.url}>
598+
<Show when={canDefault() && defaultKey() === key}>
595599
<DropdownMenu.Item onSelect={() => setDefault(null)}>
596600
<DropdownMenu.ItemLabel>
597601
{language.t("dialog.server.menu.defaultRemove")}

0 commit comments

Comments
 (0)