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,