This commit is contained in:
LukeParkerDev
2026-04-28 09:15:25 +10:00
parent 7bcb519e0c
commit ca8e6e4d67
7 changed files with 73 additions and 259 deletions

View File

@@ -12,14 +12,9 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
batch,
type Component,
createEffect,
createMemo,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
@@ -51,7 +46,6 @@ import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
@@ -167,47 +161,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
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(
healthTarget,
() =>
props.disableHealthCheck
? true
: 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(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
const healthy = createMemo(() => props.disableHealthCheck || server.healthy())
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
@@ -218,23 +172,14 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
return (
<Show when={server.ready()} fallback={splash}>
<Suspense fallback={splash}>
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={splash}
>
<Show when={healthy() !== undefined} fallback={splash}>
<Show
when={startupHealthCheck()}
when={healthy()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
startTransition(() => {
batch(() => {
setCheckMode("blocking")
server.setActive(key)
})
server.setActive(key)
})
}}
/>
@@ -248,7 +193,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) => void }) {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
@@ -259,9 +204,6 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl")
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">

View File

@@ -12,7 +12,6 @@ import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untr
import { createStore, reconcile } from "solid-js/store"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useDefaultServer } from "@/context/default-server"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
@@ -147,7 +146,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const wslServers = useWslServers()
const checkServerHealth = useCheckServerHealth()
let disposed = false
@@ -274,7 +272,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
},
onSuccess: async (key) => {
server.remove(key)
if (defaultKey() === key) await setDefault(null)
if (server.defaultKey() === key) await server.setDefault(null)
},
onError: (err) => showRequestError(language, err),
}))
@@ -549,7 +547,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if (defaultKey() === key) await setDefault(null)
if (server.defaultKey() === key) await server.setDefault(null)
}
function handleRemoveWsl(conn: ServerConnection.Any) {
@@ -621,7 +619,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => canDefault() && i.type !== "ssh"
const canChangeDefault = () => server.canDefault() && i.type !== "ssh"
const canRemove = () => i.type === "http" || wsl
const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))
const outdated = () => {
@@ -651,7 +649,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={server.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>
@@ -708,15 +706,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void setDefault(key)}>
<Show when={canChangeDefault() && server.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void server.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void setDefault(null)}>
<Show when={canChangeDefault() && server.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void server.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>

View File

@@ -6,17 +6,13 @@ 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, startTransition, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useDefaultServer } from "@/context/default-server"
import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -30,72 +26,6 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
)
}
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
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
}
pollKey()
const list = untrack(servers)
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
@@ -125,7 +55,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage()
const navigate = useNavigate()
const sdk = useSDK()
const defaultServer = useDefaultServer()
const [load, setLoad] = createStore({
lspDone: false,
@@ -193,8 +122,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
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, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -219,7 +146,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{servers().length > 0 ? `${servers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -239,45 +166,35 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
<For each={servers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left hover:bg-surface-raised-base-hover"
onClick={() => {
startTransition(() => {
batch(() => {
if (server.key !== key) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
} else {
navigate("/")
}
server.setActive(key)
})
})
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
startTransition(() => {
batch(() => {
if (server.key !== key) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
} else {
navigate("/")
}
server.setActive(key)
})
})
}}
>
<ServerHealthIndicator health={health[key]} />
>
<ServerRow
conn={s}
dimmed={blocked()}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.defaultKey()}>
<Show when={key === server.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -301,10 +218,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />,
() => void defaultServer.query.refetch(),
)
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
})
}}
>

View File

@@ -1,51 +0,0 @@
import { showToast } from "@opencode-ai/ui/toast"
import { queryOptions, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection } from "./server"
const defaultServerQueryKey = ["platform", "defaultServer"] as const
function defaultServerQueryOptions(getDefaultServer: ReturnType<typeof usePlatform>["getDefaultServer"]) {
return queryOptions<ServerConnection.Key | null>({
queryKey: defaultServerQueryKey,
queryFn: getDefaultServer
? () => getDefaultServer().then((next) => (next ? ServerConnection.Key.make(next) : null))
: skipToken,
staleTime: Number.POSITIVE_INFINITY,
})
}
export function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => ({ ...defaultServerQueryOptions(platform.getDefaultServer) }))
const mutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key | null) => {
if (!platform.setDefaultServer) return key
await platform.setDefaultServer(key)
return key
},
onSuccess: (key) => {
queryClient.setQueryData(defaultServerQueryKey, key)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
return {
canDefault: () => !!platform.getDefaultServer && !!platform.setDefaultServer,
defaultKey: () => query.data ?? null,
query,
setDefault(key: ServerConnection.Key | null) {
if (!platform.setDefaultServer) return Promise.resolve(key)
return mutation.mutateAsync(key)
},
}
}

View File

@@ -1,8 +1,11 @@
import { showToast } from "@opencode-ai/ui/toast"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -101,6 +104,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const language = useLanguage()
const platform = usePlatform()
const serversReady = () => props.serversReady ?? true
const [store, setStore, _, ready] = persisted(
@@ -139,6 +144,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
default: props.defaultServer as ServerConnection.Key | null,
healthy: undefined as boolean | undefined,
})
@@ -173,6 +179,22 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
async function setDefault(input: ServerConnection.Key | null) {
if (!platform.setDefaultServer) return input
try {
await platform.setDefaultServer(input)
setState("default", input)
return input
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
throw err
}
}
function nextActiveKey(exclude?: ServerConnection.Key) {
const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude)
const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer)
@@ -286,6 +308,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
get current() {
return current()
},
canDefault() {
return !!platform.getDefaultServer && !!platform.setDefaultServer
},
defaultKey() {
return state.default
},
setDefault,
setActive,
add,
remove,

View File

@@ -65,12 +65,11 @@ import {
PlatformProvider,
ServerConnection,
useCommand,
type WslServersEvent,
type WslServersState,
useWslServers,
} from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
@@ -354,24 +353,6 @@ render(() => {
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
const [wslServers, setWslServers] = createSignal<WslServersState | undefined>()
const [wslReady, setWslReady] = createSignal(!platform.wslServers)
if (platform.wslServers) {
void platform.wslServers
.getState()
.then((state) => {
setWslServers(state)
setWslReady(true)
})
.catch(() => {
setWslReady(true)
})
const off = platform.wslServers.subscribe((event: WslServersEvent) => {
setWslServers(event.state)
setWslReady(true)
})
onCleanup(off)
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -400,6 +381,7 @@ render(() => {
}
function App() {
const wslServers = useWslServers()
const splash = (
<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" />
@@ -428,7 +410,7 @@ render(() => {
},
})
}
for (const item of wslServers()?.servers ?? []) {
for (const item of wslServers.data?.servers ?? []) {
const runtime = item.runtime
if (runtime.kind !== "ready") continue
list.push({
@@ -453,7 +435,7 @@ render(() => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")}
serversReady={wslReady()}
serversReady={!platform.wslServers || !wslServers.isPending}
servers={servers()}
router={MemoryRouter}
>

View File

@@ -17,9 +17,9 @@
- [x] [High][M] Remove deferred terminal provider `disposeAll()` macrotask workaround unless a minimal repro proves it is still needed.
- [x] [High][M] Remove `unsupportedWorkspace` null-object fallback for WSL `/mnt/` in `context/terminal.tsx`; block earlier or let PTY failure surface.
- [x] [High][M] Fold or delete `packages/desktop-electron/src/main/wsl-pty.ts`; it has one importer and can live in `wsl.ts` if kept.
- [ ] [High][L] Remove duplicated WSL state subscription in Electron renderer or `WslServersProvider`; keep one WSL state owner.
- [ ] [High][L] Revisit `DefaultServer` context; fold into server ownership or make it a real provider instead of ad hoc query hooks.
- [ ] [High][L] Collapse duplicated server health loops/logging across `ConnectionGate`, `ServerProvider`, dialog, and status popover.
- [x] [High][L] Remove duplicated WSL state subscription in Electron renderer or `WslServersProvider`; keep one WSL state owner.
- [x] [High][L] Revisit `DefaultServer` context; fold into server ownership or make it a real provider instead of ad hoc query hooks.
- [x] [High][L] Collapse duplicated server health loops/logging across `ConnectionGate`, `ServerProvider`, dialog, and status popover.
- [ ] [Medium][S] Remove `initialView?: "add-wsl"` from `DialogSelectServer` if no callsites need it.
- [ ] [Medium][S] Remove `handleRemoveWsl`, `handleRetryWsl`, `handleUpdateWsl` guard helpers; callers already know when item is WSL.
@@ -48,7 +48,7 @@
- [ ] [Medium][M] Collapse WSL persisted config + state mutations into one owner/path, or derive runtime state instead of mutating both.
- [ ] [Medium][M] Remove bespoke WSL subscribe/unsubscribe IPC lifecycle if broadcast event pattern is acceptable.
- [ ] [Medium][M] Collapse repeated WSL IPC method lists in `ipc.ts`, `index.ts`, and preload into one simpler mapping or smaller API surface.
- [ ] [Medium][M] Remove duplicated health polling/sorting between status popover and server dialog; keep one or let dialog own detailed health.
- [x] [Medium][M] Remove duplicated health polling/sorting between status popover and server dialog; keep one or let dialog own detailed health.
- [ ] [Medium][M] Remove duplicated server-switch navigation/batching from status popover, dialog, and connection error; keep one simple path.
- [ ] [Low][S] Revert dialog timer/lock refactor if it was style churn unrelated to behavior.