diff options
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/app.tsx | 128 | ||||
| -rw-r--r-- | packages/app/src/components/dialog-select-server.tsx | 54 | ||||
| -rw-r--r-- | packages/app/src/components/status-popover.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 5 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/entry.tsx | 33 | ||||
| -rw-r--r-- | packages/app/src/utils/server-health.ts | 8 |
7 files changed, 187 insertions, 61 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2790e7d3c..1b7ffde46 100644 --- a/packages/app/src/app.tsx +++ b/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> ) } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 3ecd26910..cba401a46 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/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")} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c61b31958..8073746c9 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/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) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e4..b8ed58e34 100644 --- a/packages/app/src/context/platform.tsx +++ b/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> diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 4ff777e2e..1171ca905 100644 --- a/packages/app/src/context/server.tsx +++ b/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() diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index e9c0a4397..c62baccba 100644 --- a/packages/app/src/entry.tsx +++ b/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> ), diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index db4aa89bd..45a323c7b 100644 --- a/packages/app/src/utils/server-health.ts +++ b/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) +} |
