tinkering

This commit is contained in:
LukeParkerDev 2026-04-19 20:59:38 +10:00
parent 0e04141849
commit a17ce350f1
7 changed files with 138 additions and 82 deletions

View file

@ -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 = (
<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>
)
return (
<Suspense
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={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>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Suspense fallback={splash}>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
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}
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
void withServerSwitchOverlay(() => {
batch(() => {
setCheckMode("blocking")
server.setActive(key)
})
})
}}
/>
}
>
{props.children}
</Show>
</Show>
{/*</Show>*/}
</Suspense>
)
}

View file

@ -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<ServerConnection.Key, ServerHealth> = {}
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 = {}) {
</div>
<ServerRow
conn={i}
dimmed={health(key)?.healthy === false}
dimmed={blocked()}
status={health(key)}
version={displayVersion(i)}
class="flex items-center gap-3 min-w-0 flex-1"
@ -820,7 +831,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isWslSidecar && health(key)?.healthy === false}>
<Show when={isWslSidecar && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>

View file

@ -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<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
}
const list = servers()
pollKey()
const list = untrack(servers)
let dead = false
const refresh = async () => {
@ -277,7 +287,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
const blocked = () => isPlaceholderServerUrl(s.http.url) || health[key]?.healthy === false
return (
<button
type="button"

View file

@ -1,8 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
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 { isPlaceholderServerUrl, useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@ -220,19 +220,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return x.healthy
})
createEffect(() => {
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`)
onCleanup(startHealthPolling(current_))
})
createEffect(() => {
const key = state.active
if (typeof window === "undefined") return
@ -245,6 +232,29 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const healthTarget = createMemo(() => {
const conn = current()
if (!conn) return ""
return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n")
})
createEffect(() => {
healthTarget()
const current_ = untrack(current)
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
if (isPlaceholderServerUrl(current_.http.url)) {
setState("healthy", false)
return
}
setState("healthy", undefined)
console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`)
onCleanup(startHealthPolling(current_))
})
createEffect(() => {
const list = allServers()

View file

@ -36,6 +36,7 @@ export default function Home() {
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
function openProject(directory: string) {
layout.projects.open(directory)
@ -54,7 +55,7 @@ export default function Home() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,

View file

@ -156,6 +156,7 @@ export default function Layout(props: ParentProps) {
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
const [state, setState] = createStore({
autoselect: !initialDirectory,
@ -1471,7 +1472,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,

View file

@ -20,6 +20,14 @@ const healthCache = new Map<
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
export function isPlaceholderServerUrl(url: string) {
try {
return new URL(url).hostname.endsWith(".invalid")
} catch {
return false
}
}
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
@ -85,6 +93,7 @@ export async function checkServerHealth(
fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions,
): Promise<ServerHealth> {
if (isPlaceholderServerUrl(server.url)) return { healthy: false }
const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs)
const signal = opts?.signal ?? timeout?.signal
const retryCount = opts?.retryCount ?? defaultRetryCount