diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 326db25cb8..921ee1a649 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -16,6 +16,7 @@ import { Effect } from "effect" import { batch, type Component, + createEffect, createMemo, createResource, createSignal, @@ -49,7 +50,7 @@ import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" -import { useCheckServerHealth } from "./utils/server-health" +import { isPlaceholderServerUrl, useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) const loadSession = () => import("@/pages/session") @@ -166,67 +167,80 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checkServerHealth = useCheckServerHealth() const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + const healthTarget = createMemo(() => { + const current = server.current + if (props.disableHealthCheck || !current) return "" + return [ + ServerConnection.key(current), + current.type, + current.http.url, + current.http.username ?? "", + current.http.password ?? "", + ].join("\n") + }) + + createEffect(() => { + healthTarget() + setCheckMode("blocking") + }) // performs repeated health check with a grace period for // non-http connections, otherwise fails instantly - const [startupHealthCheck, healthCheckActions] = createResource(() => - props.disableHealthCheck - ? true - : Effect.gen(function* () { - if (!server.current) return true - const { http, type } = server.current + const [startupHealthCheck, healthCheckActions] = createResource( + healthTarget, + () => + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + if (isPlaceholderServerUrl(http.url)) return false - while (true) { - const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true - if (checkMode() === "background" || type === "http") return false - } - }).pipe( - Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), - Effect.ensuring(Effect.sync(() => setCheckMode("background"))), - Effect.runPromise, - ), + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), + ) + + const splash = ( +
+ +
) return ( - - - - } - > - {/* - - - } - >*/} - {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} + { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - void withServerSwitchOverlay(() => { - batch(() => { - setCheckMode("blocking") - server.setActive(key) - }) - void healthCheckActions.refetch() - }) - }} - /> - } + when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} + fallback={splash} > - {props.children} + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + void withServerSwitchOverlay(() => { + batch(() => { + setCheckMode("blocking") + server.setActive(key) + }) + }) + }} + /> + } + > + {props.children} + - {/**/} ) } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 200b489d96..10c533bef9 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -16,7 +16,7 @@ import { useLanguage } from "@/context/language" import type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { isPlaceholderServerUrl, type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { withServerSwitchOverlay } from "@/utils/server-switch" const DEFAULT_USERNAME = "opencode" @@ -356,6 +356,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { .join("\n\n"), ) const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) + const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url) + const wslRuntime = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + return store.wslState?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime + } + const canRetryWsl = (conn: ServerConnection.Any) => { + const runtime = wslRuntime(conn) + return runtime?.kind === "failed" || runtime?.kind === "stopped" + } const sortedItems = createMemo(() => { const list = items() @@ -378,8 +387,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function refreshHealth() { const results: Record = {} + const list = untrack(items) await Promise.all( - items().map(async (conn) => { + list.map(async (conn) => { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) @@ -430,6 +440,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!isSelectable(conn)) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return const nextKey = ServerConnection.key(conn) const changed = server.key !== nextKey @@ -468,7 +479,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const key = store.addWsl.pendingSelectKey if (!key) return const conn = items().find((item) => ServerConnection.key(item) === key) - if (!conn) return + if (!conn || !isSelectable(conn)) return const resolve = resolvePendingWslSelection resolvePendingWslSelection = undefined setStore("addWsl", "pendingSelectKey", undefined) @@ -592,7 +603,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleAddedWsl = async (distro: string) => { const key = ServerConnection.Key.make(`wsl:${distro}`) const conn = items().find((item) => ServerConnection.key(item) === key) - if (conn) { + if (conn && isSelectable(conn)) { await select(conn) return } @@ -741,8 +752,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const key = ServerConnection.key(i) const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined - const hasMenuActionsBeforeDelete = () => - i.type === "http" || (isWslSidecar && health(key)?.healthy === false) + const blocked = () => !isSelectable(i) || health(key)?.healthy === false + const hasMenuActionsBeforeDelete = () => i.type === "http" || (isWslSidecar && canRetryWsl(i)) const outdated = () => { const check = wslCheck(i) return versionOlderThan(check?.version, check?.expectedVersion) @@ -763,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { {language.t("dialog.server.menu.edit")} - + void handleRetryWsl(i)}> Retry start diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index f4ccb1b954..bc257df618 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" @@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { isPlaceholderServerUrl, useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { withServerSwitchOverlay } from "@/utils/server-switch" const pollMs = 10_000 @@ -56,13 +56,23 @@ const listServersByHealth = ( const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) + const pollKey = createMemo(() => + enabled() + ? servers() + .map((conn) => + [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"), + ) + .join("\n\n") + : "", + ) createEffect(() => { if (!enabled()) { setStatus(reconcile({})) return } - const list = servers() + pollKey() + const list = untrack(servers) let dead = false const refresh = async () => { @@ -277,7 +287,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {(s) => { const key = ServerConnection.key(s) - const blocked = () => health[key]?.healthy === false + const blocked = () => isPlaceholderServerUrl(s.http.url) || health[key]?.healthy === false return (