From 876459788f31f500760887569372a92af26aaa56 Mon Sep 17 00:00:00 2001
From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Date: Fri, 21 Aug 2026 14:30:58 +0800
Subject: [PATCH] feat(app): warn about V1 servers (#43790)
---
packages/app/src/home/projects/view.tsx | 164 ++++++++++--------
packages/app/src/new-session/route.tsx | 47 +++--
packages/app/src/runtime/i18n/en.ts | 5 +
.../app/src/runtime/server/health.test.ts | 17 ++
packages/app/src/runtime/server/health.ts | 32 ++--
packages/app/src/servers/registry/row.tsx | 24 ++-
.../src/session/incompatible-server-panel.tsx | 33 ++++
packages/app/src/session/route-error.tsx | 77 ++++----
packages/app/src/session/route.tsx | 32 +++-
9 files changed, 288 insertions(+), 143 deletions(-)
create mode 100644 packages/app/src/session/incompatible-server-panel.tsx
diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx
index 7e18665986a..51ca80b5014 100644
--- a/packages/app/src/home/projects/view.tsx
+++ b/packages/app/src/home/projects/view.tsx
@@ -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 (
-
+
+
props.onFocusServer(props.server)}
- >
- props.onFocusServer(props.server)}
+ >
+ {
- event.preventDefault()
- event.stopPropagation()
- if (!canToggle()) return
- props.onToggleCollapsed(props.server)
- }}
- onPointerDown={(event) => event.preventDefault()}
- >
-
-
-
-
-
-
- {props.server.displayName ?? new URL(props.server.http.url).host}
-
- {(label) => (
- {
+ event.preventDefault()
+ event.stopPropagation()
+ if (!canToggle()) return
+ props.onToggleCollapsed(props.server)
+ }}
+ onPointerDown={(event) => event.preventDefault()}
+ >
+
+
+
+
+
+
+
+ {props.server.displayName ?? new URL(props.server.http.url).host}
+
+
+ {(label) => (
+
- {label()}
-
- )}
-
-
-
-
+ {label()}
+
+ )}
+
+
+
+
- props.onSetDefaultServer(props.server)}
- onRemoveDefault={() => props.onSetDefaultServer(undefined)}
- onRemove={() => props.onRemoveServer(props.server)}
- open={props.contextMenuOpen(contextMenuID())}
- onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
- />
-
- }
- aria-label={props.language.t("home.project.add")}
- disabled={props.health?.healthy === false}
- onClick={() => props.onChooseProject(props.server)}
+ data-menu={props.contextMenuOpen(contextMenuID())}
+ >
+ props.onSetDefaultServer(props.server)}
+ onRemoveDefault={() => props.onSetDefaultServer(undefined)}
+ onRemove={() => props.onRemoveServer(props.server)}
+ open={props.contextMenuOpen(contextMenuID())}
+ onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
-
+
+ }
+ aria-label={props.language.t("home.project.add")}
+ disabled={props.health?.healthy === false}
+ onClick={() => props.onChooseProject(props.server)}
+ />
+
+
-
+
)
}
diff --git a/packages/app/src/new-session/route.tsx b/packages/app/src/new-session/route.tsx
index 0bdf6d4f640..1b4592cd829 100644
--- a/packages/app/src/new-session/route.tsx
+++ b/packages/app/src/new-session/route.tsx
@@ -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 }) {
{(conn) => (
-
-
-
-
-
-
-
-
-
+
)}
@@ -51,6 +45,39 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
)
}
+function ResolvedDraftContent(props: { draft: DraftTab }) {
+ const server = useServer()
+ const tabs = useTabs()
+
+ return (
+
+
+ {
+ const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID)
+ if (index !== -1) tabs.closeTab(index)
+ }}
+ />
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
// 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) {
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index cd9b9b7390e..7bf558c786c 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -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}}",
diff --git a/packages/app/src/runtime/server/health.test.ts b/packages/app/src/runtime/server/health.test.ts
index 16bbd1d7411..48ff46625d2 100644
--- a/packages/app/src/runtime/server/health.test.ts
+++ b/packages/app/src/runtime/server/health.test.ts
@@ -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
diff --git a/packages/app/src/runtime/server/health.ts b/packages/app/src/runtime/server/health.ts
index 740de5f4cd5..a381fadf005 100644
--- a/packages/app/src/runtime/server/health.ts
+++ b/packages/app/src/runtime/server/health.ts
@@ -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 }
diff --git a/packages/app/src/servers/registry/row.tsx b/packages/app/src/servers/registry/row.tsx
index 391f23c38d6..d33b631d6cf 100644
--- a/packages/app/src/servers/registry/row.tsx
+++ b/packages/app/src/servers/registry/row.tsx
@@ -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 (
-
+
+ }
+ >
+
+
)
}
diff --git a/packages/app/src/session/incompatible-server-panel.tsx b/packages/app/src/session/incompatible-server-panel.tsx
new file mode 100644
index 00000000000..744470cd45d
--- /dev/null
+++ b/packages/app/src/session/incompatible-server-panel.tsx
@@ -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 (
+
+
+
+
+
{language.t("session.error.incompatible")}
+
+ {language.t("session.error.incompatible.description", {
+ server: serverName(server.conn),
+ version: server.health?.version ?? "1",
+ })}
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/session/route-error.tsx b/packages/app/src/session/route-error.tsx
index 14c94d3efe9..526a112ce6d 100644
--- a/packages/app/src/session/route-error.tsx
+++ b/packages/app/src/session/route-error.tsx
@@ -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
+ const closeSession = () => {
+ if (!props.sessionID) return
+ tabs.removeSessionTab({ server: activeServer.key, sessionId: props.sessionID })
+ }
return (
-
-
-
-
{language.t("session.error.notFound")}
-
{language.t("session.error.notFound.description")}
-
-
- {(sessionID) => (
-
-
{displayServer()}
-
- {sessionID()}
-
+
}
+ >
+
}
+ >
+
+
+
+
{language.t("session.error.notFound")}
+
+ {language.t("session.error.notFound.description")}
+
- )}
-
-
-
-
+
+ {(sessionID) => (
+
+
{displayServer()}
+
+ {sessionID()}
+
+
+ )}
+
+
+
+
+
+
)
}
diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx
index 65fd80553ae..c5d040bf815 100644
--- a/packages/app/src/session/route.tsx
+++ b/packages/app/src/session/route.tsx
@@ -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 (
-
- {(value) => (
-
-
-
-
-
- )}
+
+
+ tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
+ />
+
+
+ }
+ >
+
+ {(value) => (
+
+
+
+
+
+ )}
+
)
}