diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx index 7e18665986..51ca80b501 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 0bdf6d4f64..1b4592cd82 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 cd9b9b7390..7bf558c786 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 16bbd1d741..48ff46625d 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 740de5f4cd..a381fadf00 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 391f23c38d..d33b631d6c 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 0000000000..744470cd45 --- /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 14c94d3efe..526a112ce6 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 65fd80553a..c5d040bf81 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) => ( + + + + + + )} + ) }