fix(app): show unreachable server state for session tabs (#56248)

Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-16 23:55:38 +02:00
committed by GitHub
Unverified
parent 141a155906
commit f0f196b308
7 changed files with 76 additions and 23 deletions
+3 -2
View File
@@ -11,7 +11,7 @@ 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 { ServerUnavailablePanel } from "@/session/server-unavailable-panel"
import { ErrorPage } from "@/shell/errors/error"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
@@ -57,7 +57,8 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
fallback={
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
<ServerUnavailablePanel
reason="incompatible"
onClose={() => {
const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID)
+3
View File
@@ -538,6 +538,9 @@ export const dict = {
"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.error.unreachable": "Can't reach this server",
"session.error.unreachable.description":
"{{server}} isn't responding, so this session can't load. It will open automatically once the server is reachable again.",
"session.background.moveRunning": "Move to background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"command.session.background": "Move to background",
+7 -9
View File
@@ -6,7 +6,7 @@ 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"
import { ServerUnavailablePanel } from "./server-unavailable-panel"
export function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
@@ -28,10 +28,14 @@ export function SessionErrorFallback(props: { error: unknown; sessionID?: string
return (
<Show
when={!activeServer.health?.incompatible}
fallback={<IncompatibleServerPanel onClose={props.sessionID ? closeSession : undefined} />}
fallback={<ServerUnavailablePanel reason="incompatible" onClose={props.sessionID ? closeSession : undefined} />}
>
<Show
when={isCurrentSessionNotFoundError(props.error, props.sessionID)}
when={
props.sessionID &&
(isSessionNotFoundError(props.error, props.sessionID) ||
isLocalSessionNotFoundError(props.error, props.sessionID))
}
fallback={<ErrorPage error={props.error} />}
>
<div class="flex-1 min-h-0 overflow-hidden">
@@ -69,9 +73,3 @@ export function SessionErrorFallback(props: { error: unknown; sessionID?: string
</Show>
)
}
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
if (!sessionID) return false
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
}
+24 -3
View File
@@ -23,7 +23,7 @@ import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame } from "./session-frame"
import { SessionIdentityHeader } from "./session-identity-header"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { ServerUnavailablePanel } from "./server-unavailable-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreenView } from "./screen"
@@ -128,18 +128,39 @@ function ResolvedTargetSessionRoute() {
const directory = createMemo(() => current()?.location.directory)
// Only a failed health check: a version mismatch has its own panel, and a rejected credential is not an outage.
const unreachable = () => {
const health = server.health
return !!health && !health.healthy && !health.checking && !health.unauthorized && !health.incompatible
}
return (
<Show
when={!server.health?.incompatible}
fallback={
<SessionStatePanel>
<IncompatibleServerPanel
<ServerUnavailablePanel
reason="incompatible"
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionStatePanel>
}
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
<Show
when={directory()}
fallback={
// Nothing is cached and an unreachable server never resolves the session, so explain instead of waiting blank.
<Show when={unreachable()} fallback={<PendingSessionState sessionID={params.id} />}>
<SessionStatePanel>
<ServerUnavailablePanel
reason="unreachable"
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionStatePanel>
</Show>
}
>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
@@ -5,7 +5,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { serverName } from "@/runtime/server/registry"
export function IncompatibleServerPanel(props: { onClose?: () => void }) {
export function ServerUnavailablePanel(props: { reason: "incompatible" | "unreachable"; onClose?: () => void }) {
const language = useLanguage()
const server = useServer()
@@ -14,12 +14,18 @@ export function IncompatibleServerPanel(props: { onClose?: () => void }) {
<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-16-medium text-text max-w-md">
{props.reason === "incompatible"
? language.t("session.error.incompatible")
: language.t("session.error.unreachable")}
</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",
})}
{props.reason === "incompatible"
? language.t("session.error.incompatible.description", {
server: serverName(server.conn),
version: server.health?.version ?? "1",
})
: language.t("session.error.unreachable.description", { server: serverName(server.conn) })}
</div>
</div>
<Show when={props.onClose}>
+24 -3
View File
@@ -30,6 +30,7 @@ export function TabNavItem(props: {
unread: boolean
loading: boolean
fallbackTitle?: string
fallbackDirectory?: string
onRename: (title: string) => Promise<void>
onClose: () => void
onNavigate: () => void
@@ -63,9 +64,11 @@ export function TabNavItem(props: {
const project = createMemo(() => {
const session = props.session
if (!session) return
if (session) return serverCtx()?.projects.forSession(session)
return serverCtx()?.projects.forSession(session)
if (!props.fallbackDirectory) return
return serverCtx()?.projects.resolve({ worktree: props.fallbackDirectory, expanded: false })
})
const title = createMemo(() => {
@@ -295,7 +298,25 @@ export function TabNavItem(props: {
<Show
when={props.preparing}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
// The remembered directory keeps the project avatar while the server is unreachable.
<Show
when={props.fallbackDirectory}
fallback={
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
}
>
{(directory) => (
<SessionTabAvatarView
project={project()}
directory={directory()}
unread={false}
loading={false}
/>
)}
</Show>
}
>
<SessionProgressIndicatorV2 />
@@ -31,6 +31,7 @@ function SessionTabSlot(props: {
unread: boolean
loading: boolean
fallbackTitle?: string
fallbackDirectory?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -70,6 +71,7 @@ function SessionTabSlot(props: {
unread={props.unread}
loading={props.loading}
fallbackTitle={props.fallbackTitle}
fallbackDirectory={props.fallbackDirectory}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
@@ -209,6 +211,7 @@ function SessionTabEntry(props: {
? language.t("session.tab.session")
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
}
fallbackDirectory={persisted()?.directory}
onRename={rename}
onNavigate={props.onNavigate}
onClose={props.onClose}