feat(app): warn about V1 servers (#43790)

This commit is contained in:
Brendan Allan 2026-08-21 14:30:58 +08:00 committed by GitHub
parent b0ab1e2992
commit 876459788f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 288 additions and 143 deletions

View file

@ -196,6 +196,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
onCleanup(() => {
@ -203,96 +204,107 @@ function HomeServerRow(props: {
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
})
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<Tooltip
appearance="standard"
placement="top"
class="flex h-7 w-full min-w-0"
inactive={!incompatible()}
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16 disabled:opacity-60"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class={`
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class={`
-ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
rounded-[4px] text-v2-icon-icon-muted
`}
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span
class={`
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed || !canToggle() ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>
{props.server.displayName ?? new URL(props.server.http.url).host}
</span>
<Show when={props.server.label}>
{(label) => (
<span
class={`
shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
text-[9px] leading-none text-v2-text-text-muted
`}
>
{label()}
</span>
)}
</Show>
</span>
</HomeProjectNavButton>
<div
class={`
>
{label()}
</span>
)}
</Show>
</span>
</HomeProjectNavButton>
<div
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
data-menu={props.contextMenuOpen(contextMenuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
</Tooltip>
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</Tooltip>
</div>
</div>
</div>
</Tooltip>
)
}

View file

@ -6,11 +6,13 @@ import { useGlobal } from "@/runtime/server/runtime"
import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider } from "@/runtime/server/current"
import { ServerProvider, useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
export function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
@ -35,15 +37,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
<ResolvedDraftContent draft={props.draft} />
</ServerProvider>
)}
</Show>
@ -51,6 +45,39 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
)
}
function ResolvedDraftContent(props: { draft: DraftTab }) {
const server = useServer()
const tabs = useTabs()
return (
<Show
when={!server.health?.incompatible}
fallback={
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
onClose={() => {
const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID)
if (index !== -1) tabs.closeTab(index)
}}
/>
</SessionPanelFrame>
</SessionRouteFrame>
}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</Show>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because Composer uses file search and comment context.
function DraftProviders(props: ParentProps) {

View file

@ -444,6 +444,8 @@ export const dict = {
"wsl.onboarding.adding": "Adding...",
"server.row.noUsername": "no username",
"server.row.incompatible":
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
"dialog.project.edit.title": "Edit project",
"dialog.project.edit.name": "Name",
@ -650,6 +652,9 @@ export const dict = {
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.incompatible": "This server version isn't supported",
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.combine": "{{first}} and {{second}}",

View file

@ -29,6 +29,23 @@ describe("checkServerHealth", () => {
expect(request?.pathname).toBe("/api/health")
})
test("identifies a V1 server without a version as incompatible", async () => {
const requests: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
requests.push(url.pathname)
return new Response(JSON.stringify(url.pathname === "/global/health" ? { version: "1.18.15" } : { healthy: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false, version: "1.18.15", incompatible: true })
expect(requests).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
let timeoutMs = 0

View file

@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string }
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean }
interface CheckServerHealthOptions {
timeoutMs?: number
@ -78,6 +78,11 @@ export async function checkServerHealth(
const signal = opts?.signal ?? timeout?.signal
const retryCount = opts?.retryCount ?? defaultRetryCount
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
const headers = server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined
const next = (count: number, error: unknown) => {
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
return wait(retryDelayMs * (count + 1), signal)
@ -88,18 +93,23 @@ export async function checkServerHealth(
const current = await OpenCode.make({
baseUrl: server.url,
fetch,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
headers,
})
.health.get({ signal })
.then((x) =>
typeof x.healthy === "boolean"
? { data: { healthy: x.healthy, version: x.version } }
: { error: new Error("Invalid health response") },
)
.then(async (x) => {
if (typeof x.healthy !== "boolean") return { error: new Error("Invalid health response") }
if (x.healthy && typeof x.version !== "string") {
const legacy = await fetch(new URL("/global/health", server.url), { headers, signal })
.then((response) => response.json())
.catch(() => undefined)
const version =
typeof legacy === "object" && legacy !== null && "version" in legacy && typeof legacy.version === "string"
? legacy.version
: "1"
return { data: { healthy: false, version, incompatible: true } }
}
return { data: { healthy: x.healthy, version: x.version } }
})
.catch((error) => ({ error }))
if ("data" in current && current.data) return current.data
if (signal?.aborted) return { healthy: false }

View file

@ -1,4 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
@ -119,13 +120,20 @@ export function ServerRow(props: ServerRowProps) {
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<div
classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
<Show
when={props.health?.incompatible}
fallback={
<div
classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
}
>
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
</Show>
)
}

View file

@ -0,0 +1,33 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { serverName } from "@/runtime/server/registry"
export function IncompatibleServerPanel(props: { onClose?: () => void }) {
const language = useLanguage()
const server = useServer()
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<Icon name="warning" size="large" class="text-icon-warning-base" />
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.incompatible")}</div>
<div class="text-13-regular text-text-weak max-w-md">
{language.t("session.error.incompatible.description", {
server: serverName(server.conn),
version: server.health?.version ?? "1",
})}
</div>
</div>
<Show when={props.onClose}>
<Button variant="neutral" size="normal" icon="xmark-small" onClick={() => props.onClose?.()}>
{language.t("session.error.notFound.closeTab")}
</Button>
</Show>
</div>
</div>
)
}

View file

@ -2,51 +2,68 @@ import { Button } from "@opencode-ai/ui/button"
import { createMemo, Show } from "solid-js"
import { ErrorPage } from "@/shell/errors/error"
import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { isLocalSessionNotFoundError, isSessionNotFoundError } from "@/runtime/server/errors"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
export function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
const activeServer = useServer()
const server = useServers()
const tabs = useTabs()
const displayServer = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === props.serverKey)
return conn ? serverName(conn) : props.serverKey
})
if (!isCurrentSessionNotFoundError(props.error, props.sessionID)) return <ErrorPage error={props.error} />
const closeSession = () => {
if (!props.sessionID) return
tabs.removeSessionTab({ server: activeServer.key, sessionId: props.sessionID })
}
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
<div class="text-13-regular text-text-weak max-w-md">{language.t("session.error.notFound.description")}</div>
</div>
<Show when={props.sessionID}>
{(sessionID) => (
<div class="max-w-full flex flex-col items-center gap-1">
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
{sessionID()}
</code>
<Show
when={!activeServer.health?.incompatible}
fallback={<IncompatibleServerPanel onClose={props.sessionID ? closeSession : undefined} />}
>
<Show
when={isCurrentSessionNotFoundError(props.error, props.sessionID)}
fallback={<ErrorPage error={props.error} />}
>
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
<div class="text-13-regular text-text-weak max-w-md">
{language.t("session.error.notFound.description")}
</div>
</div>
)}
</Show>
<Button
variant="neutral"
size="normal"
icon="xmark-small"
onClick={() => {
if (!props.sessionID || !props.serverKey) return
tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID })
}}
>
{language.t("session.error.notFound.closeTab")}
</Button>
</div>
</div>
<Show when={props.sessionID}>
{(sessionID) => (
<div class="max-w-full flex flex-col items-center gap-1">
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
{sessionID()}
</code>
</div>
)}
</Show>
<Button
variant="neutral"
size="normal"
icon="xmark-small"
onClick={() => {
if (!props.sessionID || !props.serverKey) return
tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID })
}}
>
{language.t("session.error.notFound.closeTab")}
</Button>
</div>
</div>
</Show>
</Show>
)
}

View file

@ -12,9 +12,11 @@ import { ServerConnection } from "@/runtime/server/registry"
import { TerminalProvider } from "@/session/terminal/context"
import { useSettingsCommand } from "@/settings/command"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import { useTabs } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreen } from "./screen"
@ -63,6 +65,7 @@ function SessionRouteErrorBoundary(
function ResolvedTargetSessionRoute() {
const params = useParams<{ id: string }>()
const server = useServer()
const tabs = useTabs()
const data = useData()
const current = createSessionResolution(
() => params.id,
@ -72,14 +75,27 @@ function ResolvedTargetSessionRoute() {
const directory = createMemo(() => current()?.location.directory)
return (
<Show when={directory()}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
)}
<Show
when={!server.health?.incompatible}
fallback={
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionPanelFrame>
</SessionRouteFrame>
}
>
<Show when={directory()}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
)}
</Show>
</Show>
)
}