diff --git a/packages/app/src/components/server-status-icon.css b/packages/app/src/components/server-status-icon.css
new file mode 100644
index 0000000000..4b2d74c52c
--- /dev/null
+++ b/packages/app/src/components/server-status-icon.css
@@ -0,0 +1,72 @@
+[data-component="server-status-icon"] {
+ color: var(--v2-icon-icon-muted);
+
+ [data-slot="server-status-left"],
+ [data-slot="server-status-right"] {
+ fill: none;
+ stroke: var(--server-status-color);
+ }
+
+ [data-slot="server-status-dot"] {
+ fill: var(--server-status-color);
+ }
+
+ &[data-state="reconnecting"] {
+ --server-status-color: var(--v2-state-fg-warning);
+
+ [data-slot="server-status-left"] {
+ animation: server-status-reconnecting-left 1.2s ease-in-out infinite;
+ }
+
+ [data-slot="server-status-right"] {
+ animation: server-status-reconnecting-right 1.2s ease-in-out infinite;
+ }
+ }
+
+ &[data-state="disconnected"] {
+ --server-status-color: var(--v2-state-fg-danger);
+
+ [data-slot="server-status-left"],
+ [data-slot="server-status-right"] {
+ animation: server-status-disconnected 1.2s ease-in-out infinite;
+ }
+ }
+}
+
+@keyframes server-status-reconnecting-left {
+ 0%,
+ 66%,
+ 100% {
+ opacity: 1;
+ }
+ 33% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes server-status-reconnecting-right {
+ 0%,
+ 33%,
+ 100% {
+ opacity: 1;
+ }
+ 66% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes server-status-disconnected {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-component="server-status-icon"] [data-slot^="server-status-"] {
+ animation: none;
+ }
+}
diff --git a/packages/app/src/components/server-status-icon.stories.tsx b/packages/app/src/components/server-status-icon.stories.tsx
new file mode 100644
index 0000000000..6db854856f
--- /dev/null
+++ b/packages/app/src/components/server-status-icon.stories.tsx
@@ -0,0 +1,33 @@
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { ServerStatusIcon, type ServerStatusIconState } from "./server-status-icon"
+
+export default {
+ title: "Desktop V2/Server Status Icon",
+ id: "desktop-v2-server-status-icon",
+ component: ServerStatusIcon,
+ tags: ["autodocs"],
+}
+
+function Preview(props: { state: ServerStatusIconState; label: string }) {
+ return (
+
+ }
+ />
+ {props.label}
+
+ )
+}
+
+export const Reconnecting = {
+ render: () => ,
+}
+
+export const Disconnected = {
+ render: () => ,
+}
diff --git a/packages/app/src/components/server-status-icon.test.ts b/packages/app/src/components/server-status-icon.test.ts
new file mode 100644
index 0000000000..32d5bf225f
--- /dev/null
+++ b/packages/app/src/components/server-status-icon.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "bun:test"
+import { resolveServerStatus } from "./server-status-icon"
+
+describe("resolveServerStatus", () => {
+ test("prioritizes a disconnected server over stream reconnection", () => {
+ expect(resolveServerStatus(false, "reconnecting")).toBe("disconnected")
+ })
+
+ test("shows reconnection while the server remains reachable", () => {
+ expect(resolveServerStatus(true, "reconnecting")).toBe("reconnecting")
+ })
+
+ test("stays hidden for healthy and initial connections", () => {
+ expect(resolveServerStatus(true, "connected")).toBeUndefined()
+ expect(resolveServerStatus(undefined, "connecting")).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/components/server-status-icon.tsx b/packages/app/src/components/server-status-icon.tsx
new file mode 100644
index 0000000000..4754af6291
--- /dev/null
+++ b/packages/app/src/components/server-status-icon.tsx
@@ -0,0 +1,35 @@
+import "./server-status-icon.css"
+
+export type ServerStatusIconState = "reconnecting" | "disconnected"
+export type ServerConnectionState = "connecting" | "connected" | "reconnecting"
+
+export function resolveServerStatus(healthy: boolean | undefined, connection: ServerConnectionState) {
+ if (healthy === false) return "disconnected" as const
+ if (connection === "reconnecting") return "reconnecting" as const
+}
+
+export function ServerStatusIcon(props: { state: ServerStatusIconState }) {
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 87b12e7f70..036c56caf3 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -17,7 +17,9 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
-import { useServer } from "@/context/server"
+import { ServerConnection, serverName, useServer } from "@/context/server"
+import { useServerSDK } from "@/context/server-sdk"
+import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -34,6 +36,8 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
+import { useSettingsDialog } from "../settings-dialog"
+import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
const OPEN_APPS = [
"vscode",
@@ -145,6 +149,8 @@ export function SessionHeader() {
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
+ const global = useGlobal()
+ const serverSDK = useServerSDK()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -166,6 +172,7 @@ export function SessionHeader() {
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
+ const openServerSettings = useSettingsDialog("servers")
const [exists, setExists] = createStore>>({
finder: true,
@@ -235,7 +242,23 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
+ const serverStatus = createMemo(() => {
+ const current = serverSDK()
+ return resolveServerStatus(
+ global.servers.health[ServerConnection.key(current.server)]?.healthy,
+ current.connection.status,
+ )
+ })
+ const serverStatusLabel = createMemo(() => {
+ if (serverStatus() === "disconnected") {
+ return language.t("app.server.unreachable", { server: serverName(serverSDK().server) })
+ }
+ return language.t("app.server.retrying")
+ })
const v2ActionsState = createMemo(() => ({
+ serverStatus: isDesktop() ? serverStatus() : undefined,
+ serverStatusLabel: serverStatusLabel(),
+ onServerStatusClick: openServerSettings,
toolsVisible: isDesktop() && status(),
statusLabel: language.t("status.popover.tools.trigger"),
reviewLabel: language.t("command.review.toggle"),
@@ -518,6 +541,9 @@ export function SessionHeader() {
}
type SessionHeaderV2ActionsState = {
+ serverStatus?: ServerStatusIconState
+ serverStatusLabel: string
+ onServerStatusClick: () => void
toolsVisible: boolean
statusLabel: string
reviewLabel: string
@@ -532,6 +558,21 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
+
+ {(status) => (
+
+ }
+ />
+
+ )}
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 5da65d0852..7fe3ae4a0b 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -165,7 +165,7 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
- const warning = () => props.state.serverHealth === false || props.state.issue !== undefined
+ const warning = () => props.state.issue !== undefined
const popoverProps = {
class:
diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx
index 06597e56e7..73d894468c 100644
--- a/packages/app/src/context/server-sdk.tsx
+++ b/packages/app/src/context/server-sdk.tsx
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -142,6 +143,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise | undefined
let started = false
let generation = 0
+ const [connection, setConnection] = createStore({
+ status: "connecting" as "connecting" | "connected" | "reconnecting",
+ })
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType | undefined
@@ -187,6 +191,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})
},
})
+ setConnection("status", "connected")
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
@@ -218,6 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
if (abort.signal.aborted || !started || generation !== active) return
+ setConnection("status", "reconnecting")
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
@@ -269,6 +275,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
listen: emitter.listen.bind(emitter),
start,
},
+ connection,
createClient(opts: Omit[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,